diff --git a/.env.example b/.env.example index d448ade2f..e3c3d7a2b 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ PORT=4444 ENVIRONMENT=development # Domain name for CORS origins and cookie settings (use your actual domain in production) -APP_DOMAIN=localhost +APP_DOMAIN=http://localhost # FastAPI root_path for reverse proxy deployments (empty = serve from root "/") # Used when gateway is behind a proxy with path prefix (e.g., "/api/v1") @@ -167,7 +167,7 @@ EMAIL_AUTH_ENABLED=true # Platform admin user (bootstrap from environment) # PRODUCTION: Change these to your actual admin credentials! PLATFORM_ADMIN_EMAIL=admin@example.com -PLATFORM_ADMIN_PASSWORD=changeme +PLATFORM_ADMIN_PASSWORD=Qp7$kT!3xZf8Nv@5Jm2cLh9#oG1WbE0r PLATFORM_ADMIN_FULL_NAME=Platform Administrator # Argon2id Password Hashing Configuration diff --git a/Makefile b/Makefile index a4318afb2..fa3fd0a14 100644 --- a/Makefile +++ b/Makefile @@ -135,14 +135,24 @@ update: @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -U .[dev]" # help: check-env - Verify all required env vars in .env are present -.PHONY: check-env +.PHONY: check-env check-env-dev + +# Validate .env in production mode check-env: - @echo "🔎 Checking .env against .env.example..." - @missing=0; \ - for key in $$(grep -Ev '^\s*#|^\s*$$' .env.example | cut -d= -f1); do \ - grep -q "^$$key=" .env || { echo "❌ Missing: $$key"; missing=1; }; \ - done; \ - if [ $$missing -eq 0 ]; then echo "✅ All environment variables are present."; fi + @echo "🔎 Validating .env against .env.example using Python (prod)..." + @python -m mcpgateway.scripts.validate_env .env.example + # @echo "🔎 Checking .env against .env.example..." +# @missing=0; \ +# for key in $$(grep -Ev '^\s*#|^\s*$$' .env.example | cut -d= -f1); do \ +# grep -q "^$$key=" .env || { echo "❌ Missing: $$key"; missing=1; }; \ +# done; \ +# if [ $$missing -eq 0 ]; then echo "✅ All environment variables are present."; fi + +# Validate .env in development mode (warnings do not fail) +check-env-dev: + @echo "🔎 Validating .env (dev, warnings do not fail)..." + @python -c "import sys; from mcpgateway.scripts import validate_env as ve; sys.exit(ve.main(env_file='.env', exit_on_warnings=False))" + # ============================================================================= diff --git a/docs/config.schema.json b/docs/config.schema.json new file mode 100644 index 000000000..61e957ca9 --- /dev/null +++ b/docs/config.schema.json @@ -0,0 +1,1366 @@ +{ + "description": "MCP Gateway configuration settings.\n\nExamples:\n >>> from mcpgateway.config import Settings\n >>> s = Settings(basic_auth_user='admin', basic_auth_password='secret')\n >>> s.api_key\n 'admin:secret'\n >>> s2 = Settings(transport_type='http')\n >>> s2.validate_transport() # no error\n >>> s3 = Settings(transport_type='invalid')\n >>> try:\n ... s3.validate_transport()\n ... except ValueError as e:\n ... print('error')\n error\n >>> s4 = Settings(database_url='sqlite:///./test.db')\n >>> isinstance(s4.database_settings, dict)\n True\n >>> s5 = Settings()\n >>> s5.app_name\n 'MCP_Gateway'\n >>> s5.host in ('0.0.0.0', '127.0.0.1') # Default can be either\n True\n >>> s5.port\n 4444\n >>> s5.auth_required\n True\n >>> isinstance(s5.allowed_origins, set)\n True", + "properties": { + "account_lockout_duration_minutes": { + "default": 30, + "description": "Account lockout duration in minutes", + "title": "Account Lockout Duration Minutes", + "type": "integer" + }, + "allowed_mime_types": { + "default": [ + "image/jpeg", + "text/html", + "application/json", + "text/markdown", + "application/xml", + "image/gif", + "text/plain", + "image/png" + ], + "items": { + "type": "string" + }, + "title": "Allowed Mime Types", + "type": "array", + "uniqueItems": true + }, + "allowed_origins": { + "default": [ + "http://localhost:4444", + "http://localhost" + ], + "items": { + "type": "string" + }, + "title": "Allowed Origins", + "type": "array", + "uniqueItems": true + }, + "app_domain": { + "env": "APP_DOMAIN", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "App Domain", + "type": "string" + }, + "app_name": { + "default": "MCP_Gateway", + "title": "App Name", + "type": "string" + }, + "app_root_path": { + "default": "", + "title": "App Root Path", + "type": "string" + }, + "argon2id_memory_cost": { + "default": 65536, + "description": "Argon2id memory cost in KiB", + "title": "Argon2Id Memory Cost", + "type": "integer" + }, + "argon2id_parallelism": { + "default": 1, + "description": "Argon2id parallelism (number of threads)", + "title": "Argon2Id Parallelism", + "type": "integer" + }, + "argon2id_time_cost": { + "default": 3, + "description": "Argon2id time cost (number of iterations)", + "title": "Argon2Id Time Cost", + "type": "integer" + }, + "auth_encryption_secret": { + "default": "my-test-salt", + "title": "Auth Encryption Secret", + "type": "string" + }, + "auth_required": { + "default": true, + "title": "Auth Required", + "type": "boolean" + }, + "auto_create_personal_teams": { + "default": true, + "description": "Enable automatic personal team creation for new users", + "title": "Auto Create Personal Teams", + "type": "boolean" + }, + "basic_auth_password": { + "default": "changeme", + "title": "Basic Auth Password", + "type": "string" + }, + "basic_auth_user": { + "default": "admin", + "title": "Basic Auth User", + "type": "string" + }, + "cache_prefix": { + "default": "mcpgw:", + "title": "Cache Prefix", + "type": "string" + }, + "cache_type": { + "default": "database", + "enum": [ + "redis", + "memory", + "none", + "database" + ], + "title": "Cache Type", + "type": "string" + }, + "cookie_samesite": { + "default": "lax", + "env": "COOKIE_SAMESITE", + "title": "Cookie Samesite", + "type": "string" + }, + "cors_allow_credentials": { + "default": true, + "env": "CORS_ALLOW_CREDENTIALS", + "title": "Cors Allow Credentials", + "type": "boolean" + }, + "cors_enabled": { + "default": true, + "title": "Cors Enabled", + "type": "boolean" + }, + "database_url": { + "default": "sqlite:///./mcp.db", + "title": "Database Url", + "type": "string" + }, + "db_max_overflow": { + "default": 10, + "title": "Db Max Overflow", + "type": "integer" + }, + "db_max_retries": { + "default": 3, + "title": "Db Max Retries", + "type": "integer" + }, + "db_pool_recycle": { + "default": 3600, + "title": "Db Pool Recycle", + "type": "integer" + }, + "db_pool_size": { + "default": 200, + "title": "Db Pool Size", + "type": "integer" + }, + "db_pool_timeout": { + "default": 30, + "title": "Db Pool Timeout", + "type": "integer" + }, + "db_retry_interval_ms": { + "default": 2000, + "title": "Db Retry Interval Ms", + "type": "integer" + }, + "debug": { + "default": false, + "title": "Debug", + "type": "boolean" + }, + "default_passthrough_headers": { + "items": { + "type": "string" + }, + "title": "Default Passthrough Headers", + "type": "array" + }, + "default_roots": { + "default": [], + "items": { + "type": "string" + }, + "title": "Default Roots", + "type": "array" + }, + "dev_mode": { + "default": false, + "title": "Dev Mode", + "type": "boolean" + }, + "docs_allow_basic_auth": { + "default": false, + "title": "Docs Allow Basic Auth", + "type": "boolean" + }, + "email_auth_enabled": { + "default": true, + "description": "Enable email-based authentication", + "title": "Email Auth Enabled", + "type": "boolean" + }, + "enable_header_passthrough": { + "default": false, + "description": "Enable HTTP header passthrough feature (WARNING: Security implications - only enable if needed)", + "title": "Enable Header Passthrough", + "type": "boolean" + }, + "environment": { + "default": "development", + "env": "ENVIRONMENT", + "title": "Environment", + "type": "string" + }, + "federation_discovery": { + "default": false, + "title": "Federation Discovery", + "type": "boolean" + }, + "federation_enabled": { + "default": true, + "title": "Federation Enabled", + "type": "boolean" + }, + "federation_peers": { + "anyOf": [ + { + "items": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "env": "FEDERATION_PEERS", + "title": "Federation Peers" + }, + "federation_sync_interval": { + "default": 300, + "title": "Federation Sync Interval", + "type": "integer" + }, + "federation_timeout": { + "default": 120, + "title": "Federation Timeout", + "type": "integer" + }, + "filelock_name": { + "default": "gateway_service_leader.lock", + "title": "Filelock Name", + "type": "string" + }, + "gateway_tool_name_separator": { + "default": "-", + "title": "Gateway Tool Name Separator", + "type": "string" + }, + "gateway_validation_timeout": { + "default": 5, + "title": "Gateway Validation Timeout", + "type": "integer" + }, + "health_check_interval": { + "default": 60, + "title": "Health Check Interval", + "type": "integer" + }, + "health_check_timeout": { + "default": 10, + "title": "Health Check Timeout", + "type": "integer" + }, + "host": { + "default": "127.0.0.1", + "title": "Host", + "type": "string" + }, + "hsts_enabled": { + "default": true, + "env": "HSTS_ENABLED", + "title": "Hsts Enabled", + "type": "boolean" + }, + "hsts_include_subdomains": { + "default": true, + "env": "HSTS_INCLUDE_SUBDOMAINS", + "title": "Hsts Include Subdomains", + "type": "boolean" + }, + "hsts_max_age": { + "default": 31536000, + "env": "HSTS_MAX_AGE", + "title": "Hsts Max Age", + "type": "integer" + }, + "invitation_expiry_days": { + "default": 7, + "description": "Number of days before team invitations expire", + "title": "Invitation Expiry Days", + "type": "integer" + }, + "json_response_enabled": { + "default": true, + "title": "Json Response Enabled", + "type": "boolean" + }, + "jwt_algorithm": { + "default": "HS256", + "title": "Jwt Algorithm", + "type": "string" + }, + "jwt_audience": { + "default": "mcpgateway-api", + "title": "Jwt Audience", + "type": "string" + }, + "jwt_audience_verification": { + "default": true, + "title": "Jwt Audience Verification", + "type": "boolean" + }, + "jwt_issuer": { + "default": "mcpgateway", + "title": "Jwt Issuer", + "type": "string" + }, + "jwt_private_key_path": { + "default": "", + "title": "Jwt Private Key Path", + "type": "string" + }, + "jwt_public_key_path": { + "default": "", + "title": "Jwt Public Key Path", + "type": "string" + }, + "jwt_secret_key": { + "env": "JWT_SECRET_KEY", + "format": "password", + "title": "Jwt Secret Key", + "type": "string", + "writeOnly": true + }, + "log_backup_count": { + "default": 5, + "title": "Log Backup Count", + "type": "integer" + }, + "log_buffer_size_mb": { + "default": 1.0, + "title": "Log Buffer Size Mb", + "type": "number" + }, + "log_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log File" + }, + "log_filemode": { + "default": "a+", + "title": "Log Filemode", + "type": "string" + }, + "log_folder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Folder" + }, + "log_format": { + "default": "json", + "enum": [ + "json", + "text" + ], + "title": "Log Format", + "type": "string" + }, + "log_level": { + "default": "INFO", + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "title": "Log Level", + "type": "string" + }, + "log_max_size_mb": { + "default": 1, + "title": "Log Max Size Mb", + "type": "integer" + }, + "log_rotation_enabled": { + "default": false, + "title": "Log Rotation Enabled", + "type": "boolean" + }, + "log_to_file": { + "default": false, + "title": "Log To File", + "type": "boolean" + }, + "masked_auth_value": { + "default": "*****", + "title": "Masked Auth Value", + "type": "string" + }, + "max_failed_login_attempts": { + "default": 5, + "description": "Maximum failed login attempts before account lockout", + "title": "Max Failed Login Attempts", + "type": "integer" + }, + "max_members_per_team": { + "default": 100, + "description": "Maximum number of members per team", + "title": "Max Members Per Team", + "type": "integer" + }, + "max_prompt_size": { + "default": 102400, + "title": "Max Prompt Size", + "type": "integer" + }, + "max_resource_size": { + "default": 10485760, + "title": "Max Resource Size", + "type": "integer" + }, + "max_teams_per_user": { + "default": 50, + "description": "Maximum number of teams a user can belong to", + "title": "Max Teams Per User", + "type": "integer" + }, + "max_tool_retries": { + "default": 3, + "title": "Max Tool Retries", + "type": "integer" + }, + "mcp_client_auth_enabled": { + "default": true, + "description": "Enable JWT authentication for MCP client operations", + "title": "Mcp Client Auth Enabled", + "type": "boolean" + }, + "mcpgateway_a2a_default_timeout": { + "default": 30, + "title": "Mcpgateway A2A Default Timeout", + "type": "integer" + }, + "mcpgateway_a2a_enabled": { + "default": true, + "title": "Mcpgateway A2A Enabled", + "type": "boolean" + }, + "mcpgateway_a2a_max_agents": { + "default": 100, + "title": "Mcpgateway A2A Max Agents", + "type": "integer" + }, + "mcpgateway_a2a_max_retries": { + "default": 3, + "title": "Mcpgateway A2A Max Retries", + "type": "integer" + }, + "mcpgateway_a2a_metrics_enabled": { + "default": true, + "title": "Mcpgateway A2A Metrics Enabled", + "type": "boolean" + }, + "mcpgateway_admin_api_enabled": { + "default": false, + "title": "Mcpgateway Admin Api Enabled", + "type": "boolean" + }, + "mcpgateway_bulk_import_enabled": { + "default": true, + "title": "Mcpgateway Bulk Import Enabled", + "type": "boolean" + }, + "mcpgateway_bulk_import_max_tools": { + "default": 200, + "title": "Mcpgateway Bulk Import Max Tools", + "type": "integer" + }, + "mcpgateway_bulk_import_rate_limit": { + "default": 10, + "title": "Mcpgateway Bulk Import Rate Limit", + "type": "integer" + }, + "mcpgateway_ui_enabled": { + "default": false, + "title": "Mcpgateway Ui Enabled", + "type": "boolean" + }, + "mcpgateway_ui_tool_test_timeout": { + "default": 60000, + "description": "Tool test timeout in milliseconds for the admin UI", + "title": "Mcpgateway Ui Tool Test Timeout", + "type": "integer" + }, + "message_ttl": { + "default": 600, + "title": "Message Ttl", + "type": "integer" + }, + "min_password_length": { + "default": 12, + "title": "Min Password Length", + "type": "integer" + }, + "min_secret_length": { + "default": 32, + "title": "Min Secret Length", + "type": "integer" + }, + "oauth_max_retries": { + "default": 3, + "description": "Maximum retries for OAuth token requests", + "title": "Oauth Max Retries", + "type": "integer" + }, + "oauth_request_timeout": { + "default": 30, + "description": "OAuth request timeout in seconds", + "title": "Oauth Request Timeout", + "type": "integer" + }, + "otel_bsp_max_export_batch_size": { + "default": 512, + "description": "Max export batch size", + "title": "Otel Bsp Max Export Batch Size", + "type": "integer" + }, + "otel_bsp_max_queue_size": { + "default": 2048, + "description": "Max queue size for batch span processor", + "title": "Otel Bsp Max Queue Size", + "type": "integer" + }, + "otel_bsp_schedule_delay": { + "default": 5000, + "description": "Schedule delay in milliseconds", + "title": "Otel Bsp Schedule Delay", + "type": "integer" + }, + "otel_enable_observability": { + "default": true, + "description": "Enable OpenTelemetry observability", + "title": "Otel Enable Observability", + "type": "boolean" + }, + "otel_exporter_jaeger_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Jaeger endpoint", + "title": "Otel Exporter Jaeger Endpoint" + }, + "otel_exporter_otlp_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "OTLP endpoint (e.g., http://localhost:4317)", + "title": "Otel Exporter Otlp Endpoint" + }, + "otel_exporter_otlp_headers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "OTLP headers (comma-separated key=value)", + "title": "Otel Exporter Otlp Headers" + }, + "otel_exporter_otlp_insecure": { + "default": true, + "description": "Use insecure connection for OTLP", + "title": "Otel Exporter Otlp Insecure", + "type": "boolean" + }, + "otel_exporter_otlp_protocol": { + "default": "grpc", + "description": "OTLP protocol: grpc or http", + "title": "Otel Exporter Otlp Protocol", + "type": "string" + }, + "otel_exporter_zipkin_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Zipkin endpoint", + "title": "Otel Exporter Zipkin Endpoint" + }, + "otel_resource_attributes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Resource attributes (comma-separated key=value)", + "title": "Otel Resource Attributes" + }, + "otel_service_name": { + "default": "mcp-gateway", + "description": "Service name for traces", + "title": "Otel Service Name", + "type": "string" + }, + "otel_traces_exporter": { + "default": "otlp", + "description": "Traces exporter: otlp, jaeger, zipkin, console, none", + "title": "Otel Traces Exporter", + "type": "string" + }, + "password_min_length": { + "default": 8, + "description": "Minimum password length", + "title": "Password Min Length", + "type": "integer" + }, + "password_require_lowercase": { + "default": false, + "description": "Require lowercase letters in passwords", + "title": "Password Require Lowercase", + "type": "boolean" + }, + "password_require_numbers": { + "default": false, + "description": "Require numbers in passwords", + "title": "Password Require Numbers", + "type": "boolean" + }, + "password_require_special": { + "default": false, + "description": "Require special characters in passwords", + "title": "Password Require Special", + "type": "boolean" + }, + "password_require_uppercase": { + "default": false, + "description": "Require uppercase letters in passwords", + "title": "Password Require Uppercase", + "type": "boolean" + }, + "personal_team_prefix": { + "default": "personal", + "description": "Personal team naming prefix", + "title": "Personal Team Prefix", + "type": "string" + }, + "platform_admin_email": { + "default": "admin@example.com", + "description": "Platform administrator email address", + "title": "Platform Admin Email", + "type": "string" + }, + "platform_admin_full_name": { + "default": "Platform Administrator", + "description": "Platform administrator full name", + "title": "Platform Admin Full Name", + "type": "string" + }, + "platform_admin_password": { + "default": "changeme", + "description": "Platform administrator password", + "title": "Platform Admin Password", + "type": "string" + }, + "plugin_config_file": { + "default": "plugins/config.yaml", + "description": "Path to main plugin configuration file", + "title": "Plugin Config File", + "type": "string" + }, + "plugins_cli_completion": { + "default": false, + "description": "Enable auto-completion for plugins CLI", + "title": "Plugins Cli Completion", + "type": "boolean" + }, + "plugins_cli_markup_mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Set markup mode for plugins CLI", + "title": "Plugins Cli Markup Mode" + }, + "plugins_enabled": { + "default": false, + "description": "Enable the plugin framework", + "title": "Plugins Enabled", + "type": "boolean" + }, + "port": { + "default": 4444, + "env": "PORT", + "exclusiveMinimum": 0, + "title": "Port", + "type": "integer" + }, + "prompt_cache_size": { + "default": 100, + "title": "Prompt Cache Size", + "type": "integer" + }, + "prompt_render_timeout": { + "default": 10, + "title": "Prompt Render Timeout", + "type": "integer" + }, + "protocol_version": { + "default": "2025-03-26", + "title": "Protocol Version", + "type": "string" + }, + "proxy_user_header": { + "default": "X-Authenticated-User", + "description": "Header containing authenticated username from proxy", + "title": "Proxy User Header", + "type": "string" + }, + "redis_max_retries": { + "default": 3, + "title": "Redis Max Retries", + "type": "integer" + }, + "redis_retry_interval_ms": { + "default": 2000, + "title": "Redis Retry Interval Ms", + "type": "integer" + }, + "redis_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "redis://localhost:6379/0", + "title": "Redis Url" + }, + "reload": { + "default": false, + "title": "Reload", + "type": "boolean" + }, + "remove_server_headers": { + "default": true, + "env": "REMOVE_SERVER_HEADERS", + "title": "Remove Server Headers", + "type": "boolean" + }, + "require_email_verification_for_invites": { + "default": true, + "description": "Require email verification for team invitations", + "title": "Require Email Verification For Invites", + "type": "boolean" + }, + "require_strong_secrets": { + "default": false, + "title": "Require Strong Secrets", + "type": "boolean" + }, + "require_token_expiration": { + "default": false, + "description": "Require all JWT tokens to have expiration claims", + "title": "Require Token Expiration", + "type": "boolean" + }, + "resource_cache_size": { + "default": 1000, + "title": "Resource Cache Size", + "type": "integer" + }, + "resource_cache_ttl": { + "default": 3600, + "title": "Resource Cache Ttl", + "type": "integer" + }, + "retry_base_delay": { + "default": 1.0, + "title": "Retry Base Delay", + "type": "number" + }, + "retry_jitter_max": { + "default": 0.5, + "title": "Retry Jitter Max", + "type": "number" + }, + "retry_max_attempts": { + "default": 3, + "title": "Retry Max Attempts", + "type": "integer" + }, + "retry_max_delay": { + "default": 60, + "title": "Retry Max Delay", + "type": "integer" + }, + "secure_cookies": { + "default": true, + "env": "SECURE_COOKIES", + "title": "Secure Cookies", + "type": "boolean" + }, + "security_headers_enabled": { + "default": true, + "env": "SECURITY_HEADERS_ENABLED", + "title": "Security Headers Enabled", + "type": "boolean" + }, + "session_ttl": { + "default": 3600, + "title": "Session Ttl", + "type": "integer" + }, + "skip_ssl_verify": { + "default": false, + "title": "Skip Ssl Verify", + "type": "boolean" + }, + "sse_keepalive_enabled": { + "default": true, + "title": "Sse Keepalive Enabled", + "type": "boolean" + }, + "sse_keepalive_interval": { + "default": 30, + "title": "Sse Keepalive Interval", + "type": "integer" + }, + "sse_retry_timeout": { + "default": 5000, + "title": "Sse Retry Timeout", + "type": "integer" + }, + "sso_auto_admin_domains": { + "description": "Admin domains (CSV or JSON list)", + "items": { + "type": "string" + }, + "title": "Sso Auto Admin Domains", + "type": "array" + }, + "sso_auto_create_users": { + "default": true, + "description": "Automatically create users from SSO providers", + "title": "Sso Auto Create Users", + "type": "boolean" + }, + "sso_enabled": { + "default": false, + "description": "Enable Single Sign-On authentication", + "title": "Sso Enabled", + "type": "boolean" + }, + "sso_github_admin_orgs": { + "description": "GitHub orgs granting admin (CSV/JSON)", + "items": { + "type": "string" + }, + "title": "Sso Github Admin Orgs", + "type": "array" + }, + "sso_github_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "GitHub OAuth client ID", + "title": "Sso Github Client Id" + }, + "sso_github_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "GitHub OAuth client secret", + "title": "Sso Github Client Secret" + }, + "sso_github_enabled": { + "default": false, + "description": "Enable GitHub OAuth authentication", + "title": "Sso Github Enabled", + "type": "boolean" + }, + "sso_google_admin_domains": { + "description": "Google admin domains (CSV/JSON)", + "items": { + "type": "string" + }, + "title": "Sso Google Admin Domains", + "type": "array" + }, + "sso_google_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Google OAuth client ID", + "title": "Sso Google Client Id" + }, + "sso_google_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Google OAuth client secret", + "title": "Sso Google Client Secret" + }, + "sso_google_enabled": { + "default": false, + "description": "Enable Google OAuth authentication", + "title": "Sso Google Enabled", + "type": "boolean" + }, + "sso_ibm_verify_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify client ID", + "title": "Sso Ibm Verify Client Id" + }, + "sso_ibm_verify_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify client secret", + "title": "Sso Ibm Verify Client Secret" + }, + "sso_ibm_verify_enabled": { + "default": false, + "description": "Enable IBM Security Verify OIDC authentication", + "title": "Sso Ibm Verify Enabled", + "type": "boolean" + }, + "sso_ibm_verify_issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify OIDC issuer URL", + "title": "Sso Ibm Verify Issuer" + }, + "sso_issuers": { + "anyOf": [ + { + "items": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "env": "SSO_ISSUERS", + "title": "Sso Issuers" + }, + "sso_okta_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta client ID", + "title": "Sso Okta Client Id" + }, + "sso_okta_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta client secret", + "title": "Sso Okta Client Secret" + }, + "sso_okta_enabled": { + "default": false, + "description": "Enable Okta OIDC authentication", + "title": "Sso Okta Enabled", + "type": "boolean" + }, + "sso_okta_issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta issuer URL", + "title": "Sso Okta Issuer" + }, + "sso_preserve_admin_auth": { + "default": true, + "description": "Preserve local admin authentication when SSO is enabled", + "title": "Sso Preserve Admin Auth", + "type": "boolean" + }, + "sso_require_admin_approval": { + "default": false, + "description": "Require admin approval for new SSO registrations", + "title": "Sso Require Admin Approval", + "type": "boolean" + }, + "sso_trusted_domains": { + "description": "Trusted email domains (CSV or JSON list)", + "items": { + "type": "string" + }, + "title": "Sso Trusted Domains", + "type": "array" + }, + "static_dir": { + "default": "/home/veeresh/ibm_mcp/mcp-context-forge/mcpgateway/static", + "format": "path", + "title": "Static Dir", + "type": "string" + }, + "templates_dir": { + "default": "/home/veeresh/ibm_mcp/mcp-context-forge/mcpgateway/templates", + "format": "path", + "title": "Templates Dir", + "type": "string" + }, + "token_expiry": { + "default": 10080, + "title": "Token Expiry", + "type": "integer" + }, + "tool_concurrent_limit": { + "default": 10, + "title": "Tool Concurrent Limit", + "type": "integer" + }, + "tool_rate_limit": { + "default": 100, + "title": "Tool Rate Limit", + "type": "integer" + }, + "tool_timeout": { + "default": 60, + "title": "Tool Timeout", + "type": "integer" + }, + "transport_type": { + "default": "all", + "title": "Transport Type", + "type": "string" + }, + "trust_proxy_auth": { + "default": false, + "description": "Trust proxy authentication headers (required when mcp_client_auth_enabled=false)", + "title": "Trust Proxy Auth", + "type": "boolean" + }, + "unhealthy_threshold": { + "default": 5, + "title": "Unhealthy Threshold", + "type": "integer" + }, + "use_stateful_sessions": { + "default": false, + "title": "Use Stateful Sessions", + "type": "boolean" + }, + "validation_allowed_mime_types": { + "default": [ + "text/plain", + "text/html", + "text/css", + "text/markdown", + "text/javascript", + "application/json", + "application/xml", + "application/pdf", + "image/png", + "image/jpeg", + "image/gif", + "image/svg+xml", + "application/octet-stream" + ], + "items": { + "type": "string" + }, + "title": "Validation Allowed Mime Types", + "type": "array" + }, + "validation_allowed_url_schemes": { + "default": [ + "http://", + "https://", + "ws://", + "wss://" + ], + "items": { + "type": "string" + }, + "title": "Validation Allowed Url Schemes", + "type": "array" + }, + "validation_dangerous_html_pattern": { + "default": "<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\\b|", + "title": "Validation Dangerous Html Pattern", + "type": "string" + }, + "validation_dangerous_js_pattern": { + "default": "(?i)(?:^|\\s|[\\\"'`<>=])(javascript:|vbscript:|data:\\s*[^,]*[;\\s]*(javascript|vbscript)|\\bon[a-z]+\\s*=|<\\s*script\\b)", + "title": "Validation Dangerous Js Pattern", + "type": "string" + }, + "validation_identifier_pattern": { + "default": "^[a-zA-Z0-9_\\-\\.]+$", + "title": "Validation Identifier Pattern", + "type": "string" + }, + "validation_max_content_length": { + "default": 1048576, + "title": "Validation Max Content Length", + "type": "integer" + }, + "validation_max_description_length": { + "default": 8192, + "title": "Validation Max Description Length", + "type": "integer" + }, + "validation_max_json_depth": { + "default": 10, + "title": "Validation Max Json Depth", + "type": "integer" + }, + "validation_max_method_length": { + "default": 128, + "title": "Validation Max Method Length", + "type": "integer" + }, + "validation_max_name_length": { + "default": 255, + "title": "Validation Max Name Length", + "type": "integer" + }, + "validation_max_requests_per_minute": { + "default": 60, + "title": "Validation Max Requests Per Minute", + "type": "integer" + }, + "validation_max_rpc_param_size": { + "default": 262144, + "title": "Validation Max Rpc Param Size", + "type": "integer" + }, + "validation_max_template_length": { + "default": 65536, + "title": "Validation Max Template Length", + "type": "integer" + }, + "validation_max_url_length": { + "default": 2048, + "title": "Validation Max Url Length", + "type": "integer" + }, + "validation_name_pattern": { + "default": "^[a-zA-Z0-9_.\\-\\s]+$", + "title": "Validation Name Pattern", + "type": "string" + }, + "validation_safe_uri_pattern": { + "default": "^[a-zA-Z0-9_\\-.:/?=&%]+$", + "title": "Validation Safe Uri Pattern", + "type": "string" + }, + "validation_tool_method_pattern": { + "default": "^[a-zA-Z][a-zA-Z0-9_\\./-]*$", + "title": "Validation Tool Method Pattern", + "type": "string" + }, + "validation_tool_name_pattern": { + "default": "^[a-zA-Z][a-zA-Z0-9._-]*$", + "title": "Validation Tool Name Pattern", + "type": "string" + }, + "validation_unsafe_uri_pattern": { + "default": "[<>\"\\'\\\\]", + "title": "Validation Unsafe Uri Pattern", + "type": "string" + }, + "websocket_ping_interval": { + "default": 30, + "title": "Websocket Ping Interval", + "type": "integer" + }, + "well_known_cache_max_age": { + "default": 3600, + "title": "Well Known Cache Max Age", + "type": "integer" + }, + "well_known_custom_files": { + "default": "{}", + "title": "Well Known Custom Files", + "type": "string" + }, + "well_known_enabled": { + "default": true, + "title": "Well Known Enabled", + "type": "boolean" + }, + "well_known_robots_txt": { + "default": "User-agent: *\nDisallow: /\n\n# MCP Gateway is a private API gateway\n# Public crawling is disabled by default", + "title": "Well Known Robots Txt", + "type": "string" + }, + "well_known_security_txt": { + "default": "", + "title": "Well Known Security Txt", + "type": "string" + }, + "well_known_security_txt_enabled": { + "default": false, + "title": "Well Known Security Txt Enabled", + "type": "boolean" + }, + "x_content_type_options_enabled": { + "default": true, + "env": "X_CONTENT_TYPE_OPTIONS_ENABLED", + "title": "X Content Type Options Enabled", + "type": "boolean" + }, + "x_download_options_enabled": { + "default": true, + "env": "X_DOWNLOAD_OPTIONS_ENABLED", + "title": "X Download Options Enabled", + "type": "boolean" + }, + "x_frame_options": { + "default": "DENY", + "env": "X_FRAME_OPTIONS", + "title": "X Frame Options", + "type": "string" + }, + "x_xss_protection_enabled": { + "default": true, + "env": "X_XSS_PROTECTION_ENABLED", + "title": "X Xss Protection Enabled", + "type": "boolean" + } + }, + "required": [ + "jwt_secret_key", + "app_domain" + ], + "title": "Settings", + "type": "object" +} diff --git a/docs/docs/config.schema.json b/docs/docs/config.schema.json new file mode 100644 index 000000000..6703b65ad --- /dev/null +++ b/docs/docs/config.schema.json @@ -0,0 +1,1366 @@ +{ + "description": "MCP Gateway configuration settings.\n\nExamples:\n >>> from mcpgateway.config import Settings\n >>> s = Settings(basic_auth_user='admin', basic_auth_password='secret')\n >>> s.api_key\n 'admin:secret'\n >>> s2 = Settings(transport_type='http')\n >>> s2.validate_transport() # no error\n >>> s3 = Settings(transport_type='invalid')\n >>> try:\n ... s3.validate_transport()\n ... except ValueError as e:\n ... print('error')\n error\n >>> s4 = Settings(database_url='sqlite:///./test.db')\n >>> isinstance(s4.database_settings, dict)\n True\n >>> s5 = Settings()\n >>> s5.app_name\n 'MCP_Gateway'\n >>> s5.host in ('0.0.0.0', '127.0.0.1') # Default can be either\n True\n >>> s5.port\n 4444\n >>> s5.auth_required\n True\n >>> isinstance(s5.allowed_origins, set)\n True", + "properties": { + "app_name": { + "default": "MCP_Gateway", + "title": "App Name", + "type": "string" + }, + "host": { + "default": "127.0.0.1", + "title": "Host", + "type": "string" + }, + "port": { + "default": 4444, + "env": "PORT", + "exclusiveMinimum": 0, + "title": "Port", + "type": "integer" + }, + "docs_allow_basic_auth": { + "default": false, + "title": "Docs Allow Basic Auth", + "type": "boolean" + }, + "database_url": { + "default": "sqlite:///./mcp.db", + "title": "Database Url", + "type": "string" + }, + "templates_dir": { + "default": "/home/veeresh/ibm_mcp/mcp-context-forge/mcpgateway/templates", + "format": "path", + "title": "Templates Dir", + "type": "string" + }, + "static_dir": { + "default": "/home/veeresh/ibm_mcp/mcp-context-forge/mcpgateway/static", + "format": "path", + "title": "Static Dir", + "type": "string" + }, + "app_root_path": { + "default": "", + "title": "App Root Path", + "type": "string" + }, + "protocol_version": { + "default": "2025-03-26", + "title": "Protocol Version", + "type": "string" + }, + "basic_auth_user": { + "default": "admin", + "title": "Basic Auth User", + "type": "string" + }, + "basic_auth_password": { + "default": "changeme", + "title": "Basic Auth Password", + "type": "string" + }, + "jwt_algorithm": { + "default": "HS256", + "title": "Jwt Algorithm", + "type": "string" + }, + "jwt_secret_key": { + "env": "JWT_SECRET_KEY", + "format": "password", + "title": "Jwt Secret Key", + "type": "string", + "writeOnly": true + }, + "jwt_public_key_path": { + "default": "", + "title": "Jwt Public Key Path", + "type": "string" + }, + "jwt_private_key_path": { + "default": "", + "title": "Jwt Private Key Path", + "type": "string" + }, + "jwt_audience": { + "default": "mcpgateway-api", + "title": "Jwt Audience", + "type": "string" + }, + "jwt_issuer": { + "default": "mcpgateway", + "title": "Jwt Issuer", + "type": "string" + }, + "jwt_audience_verification": { + "default": true, + "title": "Jwt Audience Verification", + "type": "boolean" + }, + "auth_required": { + "default": true, + "title": "Auth Required", + "type": "boolean" + }, + "token_expiry": { + "default": 10080, + "title": "Token Expiry", + "type": "integer" + }, + "require_token_expiration": { + "default": false, + "description": "Require all JWT tokens to have expiration claims", + "title": "Require Token Expiration", + "type": "boolean" + }, + "sso_enabled": { + "default": false, + "description": "Enable Single Sign-On authentication", + "title": "Sso Enabled", + "type": "boolean" + }, + "sso_github_enabled": { + "default": false, + "description": "Enable GitHub OAuth authentication", + "title": "Sso Github Enabled", + "type": "boolean" + }, + "sso_github_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "GitHub OAuth client ID", + "title": "Sso Github Client Id" + }, + "sso_github_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "GitHub OAuth client secret", + "title": "Sso Github Client Secret" + }, + "sso_google_enabled": { + "default": false, + "description": "Enable Google OAuth authentication", + "title": "Sso Google Enabled", + "type": "boolean" + }, + "sso_google_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Google OAuth client ID", + "title": "Sso Google Client Id" + }, + "sso_google_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Google OAuth client secret", + "title": "Sso Google Client Secret" + }, + "sso_ibm_verify_enabled": { + "default": false, + "description": "Enable IBM Security Verify OIDC authentication", + "title": "Sso Ibm Verify Enabled", + "type": "boolean" + }, + "sso_ibm_verify_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify client ID", + "title": "Sso Ibm Verify Client Id" + }, + "sso_ibm_verify_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify client secret", + "title": "Sso Ibm Verify Client Secret" + }, + "sso_ibm_verify_issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "IBM Security Verify OIDC issuer URL", + "title": "Sso Ibm Verify Issuer" + }, + "sso_okta_enabled": { + "default": false, + "description": "Enable Okta OIDC authentication", + "title": "Sso Okta Enabled", + "type": "boolean" + }, + "sso_okta_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta client ID", + "title": "Sso Okta Client Id" + }, + "sso_okta_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta client secret", + "title": "Sso Okta Client Secret" + }, + "sso_okta_issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Okta issuer URL", + "title": "Sso Okta Issuer" + }, + "sso_auto_create_users": { + "default": true, + "description": "Automatically create users from SSO providers", + "title": "Sso Auto Create Users", + "type": "boolean" + }, + "sso_trusted_domains": { + "description": "Trusted email domains (CSV or JSON list)", + "items": { + "type": "string" + }, + "title": "Sso Trusted Domains", + "type": "array" + }, + "sso_preserve_admin_auth": { + "default": true, + "description": "Preserve local admin authentication when SSO is enabled", + "title": "Sso Preserve Admin Auth", + "type": "boolean" + }, + "sso_auto_admin_domains": { + "description": "Admin domains (CSV or JSON list)", + "items": { + "type": "string" + }, + "title": "Sso Auto Admin Domains", + "type": "array" + }, + "sso_github_admin_orgs": { + "description": "GitHub orgs granting admin (CSV/JSON)", + "items": { + "type": "string" + }, + "title": "Sso Github Admin Orgs", + "type": "array" + }, + "sso_google_admin_domains": { + "description": "Google admin domains (CSV/JSON)", + "items": { + "type": "string" + }, + "title": "Sso Google Admin Domains", + "type": "array" + }, + "sso_require_admin_approval": { + "default": false, + "description": "Require admin approval for new SSO registrations", + "title": "Sso Require Admin Approval", + "type": "boolean" + }, + "mcp_client_auth_enabled": { + "default": true, + "description": "Enable JWT authentication for MCP client operations", + "title": "Mcp Client Auth Enabled", + "type": "boolean" + }, + "trust_proxy_auth": { + "default": false, + "description": "Trust proxy authentication headers (required when mcp_client_auth_enabled=false)", + "title": "Trust Proxy Auth", + "type": "boolean" + }, + "proxy_user_header": { + "default": "X-Authenticated-User", + "description": "Header containing authenticated username from proxy", + "title": "Proxy User Header", + "type": "string" + }, + "auth_encryption_secret": { + "default": "my-test-salt", + "title": "Auth Encryption Secret", + "type": "string" + }, + "oauth_request_timeout": { + "default": 30, + "description": "OAuth request timeout in seconds", + "title": "Oauth Request Timeout", + "type": "integer" + }, + "oauth_max_retries": { + "default": 3, + "description": "Maximum retries for OAuth token requests", + "title": "Oauth Max Retries", + "type": "integer" + }, + "email_auth_enabled": { + "default": true, + "description": "Enable email-based authentication", + "title": "Email Auth Enabled", + "type": "boolean" + }, + "platform_admin_email": { + "default": "admin@example.com", + "description": "Platform administrator email address", + "title": "Platform Admin Email", + "type": "string" + }, + "platform_admin_password": { + "default": "changeme", + "description": "Platform administrator password", + "title": "Platform Admin Password", + "type": "string" + }, + "platform_admin_full_name": { + "default": "Platform Administrator", + "description": "Platform administrator full name", + "title": "Platform Admin Full Name", + "type": "string" + }, + "argon2id_time_cost": { + "default": 3, + "description": "Argon2id time cost (number of iterations)", + "title": "Argon2Id Time Cost", + "type": "integer" + }, + "argon2id_memory_cost": { + "default": 65536, + "description": "Argon2id memory cost in KiB", + "title": "Argon2Id Memory Cost", + "type": "integer" + }, + "argon2id_parallelism": { + "default": 1, + "description": "Argon2id parallelism (number of threads)", + "title": "Argon2Id Parallelism", + "type": "integer" + }, + "password_min_length": { + "default": 8, + "description": "Minimum password length", + "title": "Password Min Length", + "type": "integer" + }, + "password_require_uppercase": { + "default": false, + "description": "Require uppercase letters in passwords", + "title": "Password Require Uppercase", + "type": "boolean" + }, + "password_require_lowercase": { + "default": false, + "description": "Require lowercase letters in passwords", + "title": "Password Require Lowercase", + "type": "boolean" + }, + "password_require_numbers": { + "default": false, + "description": "Require numbers in passwords", + "title": "Password Require Numbers", + "type": "boolean" + }, + "password_require_special": { + "default": false, + "description": "Require special characters in passwords", + "title": "Password Require Special", + "type": "boolean" + }, + "max_failed_login_attempts": { + "default": 5, + "description": "Maximum failed login attempts before account lockout", + "title": "Max Failed Login Attempts", + "type": "integer" + }, + "account_lockout_duration_minutes": { + "default": 30, + "description": "Account lockout duration in minutes", + "title": "Account Lockout Duration Minutes", + "type": "integer" + }, + "auto_create_personal_teams": { + "default": true, + "description": "Enable automatic personal team creation for new users", + "title": "Auto Create Personal Teams", + "type": "boolean" + }, + "personal_team_prefix": { + "default": "personal", + "description": "Personal team naming prefix", + "title": "Personal Team Prefix", + "type": "string" + }, + "max_teams_per_user": { + "default": 50, + "description": "Maximum number of teams a user can belong to", + "title": "Max Teams Per User", + "type": "integer" + }, + "max_members_per_team": { + "default": 100, + "description": "Maximum number of members per team", + "title": "Max Members Per Team", + "type": "integer" + }, + "invitation_expiry_days": { + "default": 7, + "description": "Number of days before team invitations expire", + "title": "Invitation Expiry Days", + "type": "integer" + }, + "require_email_verification_for_invites": { + "default": true, + "description": "Require email verification for team invitations", + "title": "Require Email Verification For Invites", + "type": "boolean" + }, + "mcpgateway_ui_enabled": { + "default": false, + "title": "Mcpgateway Ui Enabled", + "type": "boolean" + }, + "mcpgateway_admin_api_enabled": { + "default": false, + "title": "Mcpgateway Admin Api Enabled", + "type": "boolean" + }, + "mcpgateway_bulk_import_enabled": { + "default": true, + "title": "Mcpgateway Bulk Import Enabled", + "type": "boolean" + }, + "mcpgateway_bulk_import_max_tools": { + "default": 200, + "title": "Mcpgateway Bulk Import Max Tools", + "type": "integer" + }, + "mcpgateway_bulk_import_rate_limit": { + "default": 10, + "title": "Mcpgateway Bulk Import Rate Limit", + "type": "integer" + }, + "mcpgateway_ui_tool_test_timeout": { + "default": 60000, + "description": "Tool test timeout in milliseconds for the admin UI", + "title": "Mcpgateway Ui Tool Test Timeout", + "type": "integer" + }, + "mcpgateway_a2a_enabled": { + "default": true, + "title": "Mcpgateway A2A Enabled", + "type": "boolean" + }, + "mcpgateway_a2a_max_agents": { + "default": 100, + "title": "Mcpgateway A2A Max Agents", + "type": "integer" + }, + "mcpgateway_a2a_default_timeout": { + "default": 30, + "title": "Mcpgateway A2A Default Timeout", + "type": "integer" + }, + "mcpgateway_a2a_max_retries": { + "default": 3, + "title": "Mcpgateway A2A Max Retries", + "type": "integer" + }, + "mcpgateway_a2a_metrics_enabled": { + "default": true, + "title": "Mcpgateway A2A Metrics Enabled", + "type": "boolean" + }, + "skip_ssl_verify": { + "default": false, + "title": "Skip Ssl Verify", + "type": "boolean" + }, + "cors_enabled": { + "default": true, + "title": "Cors Enabled", + "type": "boolean" + }, + "environment": { + "default": "development", + "env": "ENVIRONMENT", + "title": "Environment", + "type": "string" + }, + "app_domain": { + "env": "APP_DOMAIN", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "App Domain", + "type": "string" + }, + "secure_cookies": { + "default": true, + "env": "SECURE_COOKIES", + "title": "Secure Cookies", + "type": "boolean" + }, + "cookie_samesite": { + "default": "lax", + "env": "COOKIE_SAMESITE", + "title": "Cookie Samesite", + "type": "string" + }, + "cors_allow_credentials": { + "default": true, + "env": "CORS_ALLOW_CREDENTIALS", + "title": "Cors Allow Credentials", + "type": "boolean" + }, + "security_headers_enabled": { + "default": true, + "env": "SECURITY_HEADERS_ENABLED", + "title": "Security Headers Enabled", + "type": "boolean" + }, + "x_frame_options": { + "default": "DENY", + "env": "X_FRAME_OPTIONS", + "title": "X Frame Options", + "type": "string" + }, + "x_content_type_options_enabled": { + "default": true, + "env": "X_CONTENT_TYPE_OPTIONS_ENABLED", + "title": "X Content Type Options Enabled", + "type": "boolean" + }, + "x_xss_protection_enabled": { + "default": true, + "env": "X_XSS_PROTECTION_ENABLED", + "title": "X Xss Protection Enabled", + "type": "boolean" + }, + "x_download_options_enabled": { + "default": true, + "env": "X_DOWNLOAD_OPTIONS_ENABLED", + "title": "X Download Options Enabled", + "type": "boolean" + }, + "hsts_enabled": { + "default": true, + "env": "HSTS_ENABLED", + "title": "Hsts Enabled", + "type": "boolean" + }, + "hsts_max_age": { + "default": 31536000, + "env": "HSTS_MAX_AGE", + "title": "Hsts Max Age", + "type": "integer" + }, + "hsts_include_subdomains": { + "default": true, + "env": "HSTS_INCLUDE_SUBDOMAINS", + "title": "Hsts Include Subdomains", + "type": "boolean" + }, + "remove_server_headers": { + "default": true, + "env": "REMOVE_SERVER_HEADERS", + "title": "Remove Server Headers", + "type": "boolean" + }, + "allowed_origins": { + "default": [ + "http://localhost:4444", + "http://localhost" + ], + "items": { + "type": "string" + }, + "title": "Allowed Origins", + "type": "array", + "uniqueItems": true + }, + "min_secret_length": { + "default": 32, + "title": "Min Secret Length", + "type": "integer" + }, + "min_password_length": { + "default": 12, + "title": "Min Password Length", + "type": "integer" + }, + "require_strong_secrets": { + "default": false, + "title": "Require Strong Secrets", + "type": "boolean" + }, + "retry_max_attempts": { + "default": 3, + "title": "Retry Max Attempts", + "type": "integer" + }, + "retry_base_delay": { + "default": 1.0, + "title": "Retry Base Delay", + "type": "number" + }, + "retry_max_delay": { + "default": 60, + "title": "Retry Max Delay", + "type": "integer" + }, + "retry_jitter_max": { + "default": 0.5, + "title": "Retry Jitter Max", + "type": "number" + }, + "log_level": { + "default": "INFO", + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "title": "Log Level", + "type": "string" + }, + "log_format": { + "default": "json", + "enum": [ + "json", + "text" + ], + "title": "Log Format", + "type": "string" + }, + "log_to_file": { + "default": false, + "title": "Log To File", + "type": "boolean" + }, + "log_filemode": { + "default": "a+", + "title": "Log Filemode", + "type": "string" + }, + "log_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log File" + }, + "log_folder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Folder" + }, + "log_rotation_enabled": { + "default": false, + "title": "Log Rotation Enabled", + "type": "boolean" + }, + "log_max_size_mb": { + "default": 1, + "title": "Log Max Size Mb", + "type": "integer" + }, + "log_backup_count": { + "default": 5, + "title": "Log Backup Count", + "type": "integer" + }, + "log_buffer_size_mb": { + "default": 1.0, + "title": "Log Buffer Size Mb", + "type": "number" + }, + "transport_type": { + "default": "all", + "title": "Transport Type", + "type": "string" + }, + "websocket_ping_interval": { + "default": 30, + "title": "Websocket Ping Interval", + "type": "integer" + }, + "sse_retry_timeout": { + "default": 5000, + "title": "Sse Retry Timeout", + "type": "integer" + }, + "sse_keepalive_enabled": { + "default": true, + "title": "Sse Keepalive Enabled", + "type": "boolean" + }, + "sse_keepalive_interval": { + "default": 30, + "title": "Sse Keepalive Interval", + "type": "integer" + }, + "federation_enabled": { + "default": true, + "title": "Federation Enabled", + "type": "boolean" + }, + "federation_discovery": { + "default": false, + "title": "Federation Discovery", + "type": "boolean" + }, + "federation_peers": { + "anyOf": [ + { + "items": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "env": "FEDERATION_PEERS", + "title": "Federation Peers" + }, + "federation_timeout": { + "default": 120, + "title": "Federation Timeout", + "type": "integer" + }, + "federation_sync_interval": { + "default": 300, + "title": "Federation Sync Interval", + "type": "integer" + }, + "sso_issuers": { + "anyOf": [ + { + "items": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "env": "SSO_ISSUERS", + "title": "Sso Issuers" + }, + "resource_cache_size": { + "default": 1000, + "title": "Resource Cache Size", + "type": "integer" + }, + "resource_cache_ttl": { + "default": 3600, + "title": "Resource Cache Ttl", + "type": "integer" + }, + "max_resource_size": { + "default": 10485760, + "title": "Max Resource Size", + "type": "integer" + }, + "allowed_mime_types": { + "default": [ + "text/plain", + "application/json", + "image/jpeg", + "image/png", + "text/markdown", + "application/xml", + "image/gif", + "text/html" + ], + "items": { + "type": "string" + }, + "title": "Allowed Mime Types", + "type": "array", + "uniqueItems": true + }, + "tool_timeout": { + "default": 60, + "title": "Tool Timeout", + "type": "integer" + }, + "max_tool_retries": { + "default": 3, + "title": "Max Tool Retries", + "type": "integer" + }, + "tool_rate_limit": { + "default": 100, + "title": "Tool Rate Limit", + "type": "integer" + }, + "tool_concurrent_limit": { + "default": 10, + "title": "Tool Concurrent Limit", + "type": "integer" + }, + "prompt_cache_size": { + "default": 100, + "title": "Prompt Cache Size", + "type": "integer" + }, + "max_prompt_size": { + "default": 102400, + "title": "Max Prompt Size", + "type": "integer" + }, + "prompt_render_timeout": { + "default": 10, + "title": "Prompt Render Timeout", + "type": "integer" + }, + "health_check_interval": { + "default": 60, + "title": "Health Check Interval", + "type": "integer" + }, + "health_check_timeout": { + "default": 10, + "title": "Health Check Timeout", + "type": "integer" + }, + "unhealthy_threshold": { + "default": 5, + "title": "Unhealthy Threshold", + "type": "integer" + }, + "gateway_validation_timeout": { + "default": 5, + "title": "Gateway Validation Timeout", + "type": "integer" + }, + "filelock_name": { + "default": "gateway_service_leader.lock", + "title": "Filelock Name", + "type": "string" + }, + "default_roots": { + "default": [], + "items": { + "type": "string" + }, + "title": "Default Roots", + "type": "array" + }, + "db_pool_size": { + "default": 200, + "title": "Db Pool Size", + "type": "integer" + }, + "db_max_overflow": { + "default": 10, + "title": "Db Max Overflow", + "type": "integer" + }, + "db_pool_timeout": { + "default": 30, + "title": "Db Pool Timeout", + "type": "integer" + }, + "db_pool_recycle": { + "default": 3600, + "title": "Db Pool Recycle", + "type": "integer" + }, + "db_max_retries": { + "default": 3, + "title": "Db Max Retries", + "type": "integer" + }, + "db_retry_interval_ms": { + "default": 2000, + "title": "Db Retry Interval Ms", + "type": "integer" + }, + "cache_type": { + "default": "database", + "enum": [ + "redis", + "memory", + "none", + "database" + ], + "title": "Cache Type", + "type": "string" + }, + "redis_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "redis://localhost:6379/0", + "title": "Redis Url" + }, + "cache_prefix": { + "default": "mcpgw:", + "title": "Cache Prefix", + "type": "string" + }, + "session_ttl": { + "default": 3600, + "title": "Session Ttl", + "type": "integer" + }, + "message_ttl": { + "default": 600, + "title": "Message Ttl", + "type": "integer" + }, + "redis_max_retries": { + "default": 3, + "title": "Redis Max Retries", + "type": "integer" + }, + "redis_retry_interval_ms": { + "default": 2000, + "title": "Redis Retry Interval Ms", + "type": "integer" + }, + "use_stateful_sessions": { + "default": false, + "title": "Use Stateful Sessions", + "type": "boolean" + }, + "json_response_enabled": { + "default": true, + "title": "Json Response Enabled", + "type": "boolean" + }, + "plugins_enabled": { + "default": false, + "description": "Enable the plugin framework", + "title": "Plugins Enabled", + "type": "boolean" + }, + "plugin_config_file": { + "default": "plugins/config.yaml", + "description": "Path to main plugin configuration file", + "title": "Plugin Config File", + "type": "string" + }, + "plugins_cli_completion": { + "default": false, + "description": "Enable auto-completion for plugins CLI", + "title": "Plugins Cli Completion", + "type": "boolean" + }, + "plugins_cli_markup_mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Set markup mode for plugins CLI", + "title": "Plugins Cli Markup Mode" + }, + "dev_mode": { + "default": false, + "title": "Dev Mode", + "type": "boolean" + }, + "reload": { + "default": false, + "title": "Reload", + "type": "boolean" + }, + "debug": { + "default": false, + "title": "Debug", + "type": "boolean" + }, + "otel_enable_observability": { + "default": true, + "description": "Enable OpenTelemetry observability", + "title": "Otel Enable Observability", + "type": "boolean" + }, + "otel_traces_exporter": { + "default": "otlp", + "description": "Traces exporter: otlp, jaeger, zipkin, console, none", + "title": "Otel Traces Exporter", + "type": "string" + }, + "otel_exporter_otlp_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "OTLP endpoint (e.g., http://localhost:4317)", + "title": "Otel Exporter Otlp Endpoint" + }, + "otel_exporter_otlp_protocol": { + "default": "grpc", + "description": "OTLP protocol: grpc or http", + "title": "Otel Exporter Otlp Protocol", + "type": "string" + }, + "otel_exporter_otlp_insecure": { + "default": true, + "description": "Use insecure connection for OTLP", + "title": "Otel Exporter Otlp Insecure", + "type": "boolean" + }, + "otel_exporter_otlp_headers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "OTLP headers (comma-separated key=value)", + "title": "Otel Exporter Otlp Headers" + }, + "otel_exporter_jaeger_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Jaeger endpoint", + "title": "Otel Exporter Jaeger Endpoint" + }, + "otel_exporter_zipkin_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Zipkin endpoint", + "title": "Otel Exporter Zipkin Endpoint" + }, + "otel_service_name": { + "default": "mcp-gateway", + "description": "Service name for traces", + "title": "Otel Service Name", + "type": "string" + }, + "otel_resource_attributes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Resource attributes (comma-separated key=value)", + "title": "Otel Resource Attributes" + }, + "otel_bsp_max_queue_size": { + "default": 2048, + "description": "Max queue size for batch span processor", + "title": "Otel Bsp Max Queue Size", + "type": "integer" + }, + "otel_bsp_max_export_batch_size": { + "default": 512, + "description": "Max export batch size", + "title": "Otel Bsp Max Export Batch Size", + "type": "integer" + }, + "otel_bsp_schedule_delay": { + "default": 5000, + "description": "Schedule delay in milliseconds", + "title": "Otel Bsp Schedule Delay", + "type": "integer" + }, + "well_known_enabled": { + "default": true, + "title": "Well Known Enabled", + "type": "boolean" + }, + "well_known_robots_txt": { + "default": "User-agent: *\nDisallow: /\n\n# MCP Gateway is a private API gateway\n# Public crawling is disabled by default", + "title": "Well Known Robots Txt", + "type": "string" + }, + "well_known_security_txt": { + "default": "", + "title": "Well Known Security Txt", + "type": "string" + }, + "well_known_security_txt_enabled": { + "default": false, + "title": "Well Known Security Txt Enabled", + "type": "boolean" + }, + "well_known_custom_files": { + "default": "{}", + "title": "Well Known Custom Files", + "type": "string" + }, + "well_known_cache_max_age": { + "default": 3600, + "title": "Well Known Cache Max Age", + "type": "integer" + }, + "gateway_tool_name_separator": { + "default": "-", + "title": "Gateway Tool Name Separator", + "type": "string" + }, + "validation_dangerous_html_pattern": { + "default": "<(script|iframe|object|embed|link|meta|base|form|img|svg|video|audio|source|track|area|map|canvas|applet|frame|frameset|html|head|body|style)\\b|", + "title": "Validation Dangerous Html Pattern", + "type": "string" + }, + "validation_dangerous_js_pattern": { + "default": "(?i)(?:^|\\s|[\\\"'`<>=])(javascript:|vbscript:|data:\\s*[^,]*[;\\s]*(javascript|vbscript)|\\bon[a-z]+\\s*=|<\\s*script\\b)", + "title": "Validation Dangerous Js Pattern", + "type": "string" + }, + "validation_allowed_url_schemes": { + "default": [ + "http://", + "https://", + "ws://", + "wss://" + ], + "items": { + "type": "string" + }, + "title": "Validation Allowed Url Schemes", + "type": "array" + }, + "validation_name_pattern": { + "default": "^[a-zA-Z0-9_.\\-\\s]+$", + "title": "Validation Name Pattern", + "type": "string" + }, + "validation_identifier_pattern": { + "default": "^[a-zA-Z0-9_\\-\\.]+$", + "title": "Validation Identifier Pattern", + "type": "string" + }, + "validation_safe_uri_pattern": { + "default": "^[a-zA-Z0-9_\\-.:/?=&%]+$", + "title": "Validation Safe Uri Pattern", + "type": "string" + }, + "validation_unsafe_uri_pattern": { + "default": "[<>\"\\'\\\\]", + "title": "Validation Unsafe Uri Pattern", + "type": "string" + }, + "validation_tool_name_pattern": { + "default": "^[a-zA-Z][a-zA-Z0-9._-]*$", + "title": "Validation Tool Name Pattern", + "type": "string" + }, + "validation_tool_method_pattern": { + "default": "^[a-zA-Z][a-zA-Z0-9_\\./-]*$", + "title": "Validation Tool Method Pattern", + "type": "string" + }, + "validation_max_name_length": { + "default": 255, + "title": "Validation Max Name Length", + "type": "integer" + }, + "validation_max_description_length": { + "default": 8192, + "title": "Validation Max Description Length", + "type": "integer" + }, + "validation_max_template_length": { + "default": 65536, + "title": "Validation Max Template Length", + "type": "integer" + }, + "validation_max_content_length": { + "default": 1048576, + "title": "Validation Max Content Length", + "type": "integer" + }, + "validation_max_json_depth": { + "default": 10, + "title": "Validation Max Json Depth", + "type": "integer" + }, + "validation_max_url_length": { + "default": 2048, + "title": "Validation Max Url Length", + "type": "integer" + }, + "validation_max_rpc_param_size": { + "default": 262144, + "title": "Validation Max Rpc Param Size", + "type": "integer" + }, + "validation_max_method_length": { + "default": 128, + "title": "Validation Max Method Length", + "type": "integer" + }, + "validation_allowed_mime_types": { + "default": [ + "text/plain", + "text/html", + "text/css", + "text/markdown", + "text/javascript", + "application/json", + "application/xml", + "application/pdf", + "image/png", + "image/jpeg", + "image/gif", + "image/svg+xml", + "application/octet-stream" + ], + "items": { + "type": "string" + }, + "title": "Validation Allowed Mime Types", + "type": "array" + }, + "validation_max_requests_per_minute": { + "default": 60, + "title": "Validation Max Requests Per Minute", + "type": "integer" + }, + "enable_header_passthrough": { + "default": false, + "description": "Enable HTTP header passthrough feature (WARNING: Security implications - only enable if needed)", + "title": "Enable Header Passthrough", + "type": "boolean" + }, + "default_passthrough_headers": { + "items": { + "type": "string" + }, + "title": "Default Passthrough Headers", + "type": "array" + }, + "masked_auth_value": { + "default": "*****", + "title": "Masked Auth Value", + "type": "string" + } + }, + "required": [ + "jwt_secret_key", + "app_domain" + ], + "title": "Settings", + "type": "object" +} diff --git a/docs/docs/operations/config-validation.md b/docs/docs/operations/config-validation.md new file mode 100644 index 000000000..1c42f42df --- /dev/null +++ b/docs/docs/operations/config-validation.md @@ -0,0 +1,159 @@ +# Configuration Validation + +MCP Gateway provides robust configuration validation tools to help operators catch misconfigurations early and ensure reliable deployments. + +## JSON Schema Export + +Generate a machine-readable schema for all configuration options: + +```bash +# Export schema to stdout +python -m mcpgateway.config --schema + +# Save schema to file +python -m mcpgateway.config --schema > config.schema.json +``` + +Use this schema with validation tools in your deployment pipeline: + +```bash +# Validate with ajv-cli +npx ajv-cli validate -s config.schema.json -d .env.json + +# Validate with jsonschema (Python) +python -c " +import json, jsonschema +from mcpgateway.config import generate_settings_schema +schema = generate_settings_schema() +with open('.env.json') as f: + jsonschema.validate(json.load(f), schema) +" +``` + +## Environment File Validation + +Validate your `.env` file before deployment: + +```bash +# Validate default .env file +python -m mcpgateway.scripts.validate_env + +# Validate specific file +python -m mcpgateway.scripts.validate_env .env.example + +# Use in Makefile +make check-env +``` + +The validator checks for: +- **Type validation**: Ensures values match expected types (integers, URLs, enums) +- **Security warnings**: Detects weak passwords, default secrets, insecure configurations +- **Range validation**: Verifies ports, timeouts, and limits are within valid ranges +- **Format validation**: Validates URLs, email addresses, and structured data + +## Example Validation Output + +### Valid Configuration +```bash +$ python -m mcpgateway.scripts.validate_env .env.example +✅ .env validated successfully with no warnings. +``` + +### Invalid Configuration +```bash +$ python -m mcpgateway.scripts.validate_env .env.invalid +❌ Invalid configuration: ValidationError +2 validation errors for Settings +port + Input should be greater than 0 [type=greater_than, input=-1] +log_level + Input should be 'DEBUG', 'INFO', 'WARNING', 'ERROR' or 'CRITICAL' [type=literal_error, input='INVALID'] +``` + +### Security Warnings +```bash +$ python -m mcpgateway.scripts.validate_env .env.dev +⚠️ Default admin password detected! Please change PLATFORM_ADMIN_PASSWORD immediately. +⚠️ JWT_SECRET_KEY: Default/weak secret detected! Please set a strong, unique value for production. +❌ Configuration has security warnings. Please address them for production use. +``` + +## CI/CD Integration + +Add validation to your deployment pipeline: + +### GitHub Actions +```yaml +- name: Validate Configuration + run: | + python -m mcpgateway.scripts.validate_env .env.production + if [ $? -ne 0 ]; then + echo "Configuration validation failed" + exit 1 + fi +``` + +### Docker Build +```dockerfile +COPY .env.example /app/.env +RUN python -m mcpgateway.scripts.validate_env /app/.env +``` + +## Configuration Types + +The following field types are strictly validated: + +### URLs and Endpoints +- `APP_DOMAIN`: Must be valid HTTP/HTTPS URL +- `FEDERATION_PEERS`: JSON array of valid URLs +- `SSO_*_ISSUER`: Valid OIDC issuer URLs + +### Enumerations +- `LOG_LEVEL`: DEBUG, INFO, WARNING, ERROR, CRITICAL +- `CACHE_TYPE`: memory, redis, database +- `TRANSPORT_TYPE`: http, ws, sse, all + +### Numeric Ranges +- `PORT`: 1-65535 +- `DB_POOL_SIZE`: Positive integer +- `TOKEN_EXPIRY`: Positive integer (minutes) + +### Security Fields +- `JWT_SECRET_KEY`: SecretStr, minimum 32 characters +- `AUTH_ENCRYPTION_SECRET`: SecretStr, minimum 32 characters +- Password fields: Minimum 12 characters with complexity requirements + +## Best Practices + +1. **Validate Early**: Run validation in development and CI before deployment +2. **Use Strong Secrets**: Generate cryptographically secure secrets for production +3. **Environment-Specific Configs**: Maintain separate `.env` files per environment +4. **Schema Versioning**: Pin schema versions in deployment scripts +5. **Security Scanning**: Regularly audit configurations for security issues + +## Troubleshooting + +### Common Validation Errors + +**Invalid Port Range** +``` +port: Input should be greater than 0 and less than 65536 +``` +Fix: Use valid port number (1-65535) + +**Invalid URL Format** +``` +app_domain: Input should be a valid URL +``` +Fix: Ensure URLs include protocol (`http://` or `https://`) + +**Weak Secrets** +``` +JWT_SECRET_KEY: Secret should be at least 32 characters long +Admin password should be at least 8 characters long +``` +Fix: Generate longer, more complex secrets + +### Getting Help + +- Use `python -m mcpgateway.config --help` for CLI options diff --git a/mcpgateway/cli.py b/mcpgateway/cli.py index 2f18824fc..90c7c065c 100644 --- a/mcpgateway/cli.py +++ b/mcpgateway/cli.py @@ -37,15 +37,19 @@ from __future__ import annotations # Standard +import json import os +from pathlib import Path import sys -from typing import List +from typing import List, Optional # Third-Party +from pydantic import ValidationError import uvicorn # First-Party from mcpgateway import __version__ +from mcpgateway.config import Settings # --------------------------------------------------------------------------- # Configuration defaults (overridable via environment variables) @@ -119,6 +123,66 @@ def _insert_defaults(raw_args: List[str]) -> List[str]: return args +def _handle_validate_config(path: str = ".env") -> None: + """ + Validate the application's environment configuration file. + + Attempts to load settings from the specified .env file using Pydantic. + If validation fails, prints the errors and exits with code 1. + On success, prints a confirmation message. + + Args: + path (str): Path to the .env file to validate. Defaults to ".env". + + Raises: + SystemExit: Exits with code 1 if the configuration is invalid. + + Examples: + >>> _handle_validate_config(".env.example") + ✅ Configuration in .env.example is valid + """ + + try: + Settings(_env_file=path) + except ValidationError as exc: + print(f"❌ Invalid configuration in {path}", file=sys.stderr) + print(exc.json(indent=2), file=sys.stderr) + raise SystemExit(1) + + print(f"✅ Configuration in {path} is valid") + + +def _handle_config_schema(output: Optional[str] = None) -> None: + """ + Export the JSON schema for MCP Gateway Settings. + + This function serializes the Pydantic Settings model into a JSON Schema + suitable for validation or documentation purposes. + + Args: + output (Optional[str]): Optional file path to write the schema. + If None, prints to stdout. + + Examples: + >>> # Print schema to stdout (output truncated for doctest) + >>> _handle_config_schema() # doctest: +ELLIPSIS + {... + + >>> # Write schema to a file (creates 'schema.json'), skip doctest + >>> _handle_config_schema("schema.json") # doctest: +SKIP + ✅ Schema written to schema.json + """ + schema = Settings.model_json_schema(mode="validation") + data = json.dumps(schema, indent=2, sort_keys=True) + + if output: + path = Path(output) + path.write_text(data, encoding="utf-8") + print(f"✅ Schema written to {path}") + else: + print(data) + + # --------------------------------------------------------------------------- # Public entry-point # --------------------------------------------------------------------------- @@ -135,6 +199,16 @@ def main() -> None: # noqa: D401 - imperative mood is fine here Environment Variables: MCG_HOST: Default host (default: "127.0.0.1") MCG_PORT: Default port (default: "4444") + + Usage: + mcpgateway --reload + mcpgateway --workers 4 + mcpgateway --validate-config [path] + mcpgateway --config-schema [output] + + Flags: + --validate-config [path] Validate .env file (default: .env) + --config-schema [output] Print or write JSON schema for Settings """ # Check for export/import commands first @@ -151,6 +225,20 @@ def main() -> None: # noqa: D401 - imperative mood is fine here print(f"mcpgateway {__version__}") return + # Handle config-related flags + if len(sys.argv) > 1: + cmd = sys.argv[1] + + if cmd == "--validate-config": + env_path = sys.argv[2] if len(sys.argv) > 2 else ".env" + _handle_validate_config(env_path) + return + + if cmd == "--config-schema": + output = sys.argv[2] if len(sys.argv) > 2 else None + _handle_config_schema(output) + return + # Discard the program name and inspect the rest. user_args = sys.argv[1:] uvicorn_argv = _insert_defaults(user_args) diff --git a/mcpgateway/config.py b/mcpgateway/config.py index 8e43e70db..b815a0a6d 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -55,14 +55,15 @@ import os from pathlib import Path import re -from typing import Annotated, Any, ClassVar, Dict, List, Optional, Set, Union +import sys +from typing import Annotated, Any, ClassVar, Dict, List, Literal, Optional, Set, Union # Third-Party from fastapi import HTTPException import jq from jsonpath_ng.ext import parse from jsonpath_ng.jsonpath import JSONPath -from pydantic import Field, field_validator, model_validator +from pydantic import Field, field_validator, HttpUrl, model_validator, PositiveInt, SecretStr from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict # Only configure basic logging if no handlers exist yet @@ -152,7 +153,7 @@ class Settings(BaseSettings): # Basic Settings app_name: str = "MCP_Gateway" host: str = "127.0.0.1" - port: int = 4444 + port: PositiveInt = Field(default=4444, ge=1, le=65535, env="PORT") docs_allow_basic_auth: bool = False # Allow basic auth for docs database_url: str = "sqlite:///./mcp.db" templates_dir: Path = Path("mcpgateway/templates") @@ -168,7 +169,7 @@ class Settings(BaseSettings): basic_auth_user: str = "admin" basic_auth_password: str = "changeme" jwt_algorithm: str = "HS256" - jwt_secret_key: str = "my-test-key" + jwt_secret_key: SecretStr = Field(default="my-test-key", env="JWT_SECRET_KEY") jwt_public_key_path: str = "" jwt_private_key_path: str = "" jwt_audience: str = "mcpgateway-api" @@ -219,7 +220,7 @@ class Settings(BaseSettings): proxy_user_header: str = Field(default="X-Authenticated-User", description="Header containing authenticated username from proxy") # Encryption key phrase for auth storage - auth_encryption_secret: str = "my-test-salt" + auth_encryption_secret: SecretStr = Field(default="my-test-salt", env="AUTH_ENCRYPTION_SECRET") # OAuth Configuration oauth_request_timeout: int = Field(default=30, description="OAuth request timeout in seconds") @@ -277,10 +278,10 @@ class Settings(BaseSettings): cors_enabled: bool = True # Environment - environment: str = Field(default="development", env="ENVIRONMENT") + environment: Literal["development", "staging", "production"] = Field(default="development", env="ENVIRONMENT") # Domain configuration - app_domain: str = Field(default="localhost", env="APP_DOMAIN") + app_domain: HttpUrl = Field(default="http://localhost:4444", env="APP_DOMAIN") # Security settings secure_cookies: bool = Field(default=True, env="SECURE_COOKIES") @@ -314,32 +315,55 @@ class Settings(BaseSettings): @field_validator("jwt_secret_key", "auth_encryption_secret") @classmethod - def validate_secrets(cls, v: str, info) -> str: - """Validate secret keys meet security requirements. + def validate_secrets(cls, v, info): + """ + Validate that secret keys meet basic security requirements. + + This validator is applied to the `jwt_secret_key` and `auth_encryption_secret` fields. + It performs the following checks: + + 1. Detects default or weak secrets (e.g., "changeme", "secret", "password"). + Logs a warning if detected. + + 2. Checks minimum length (at least 32 characters). Logs a warning if shorter. + + 3. Performs a basic entropy check (at least 10 unique characters). Logs a warning if low. + + Notes: + - Logging is used for warnings; the function does not raise exceptions. + - The original value is returned as a `SecretStr` for safe handling. Args: - v: The secret key value to validate. - info: ValidationInfo containing field metadata. + v (str | SecretStr): The secret value to validate. + info: Pydantic validation info object, used to get the field name. Returns: - str: The validated secret key value. + SecretStr: The validated secret value, wrapped as a SecretStr if it wasn't already. """ + field_name = info.field_name + # Extract actual string value safely + if isinstance(v, SecretStr): + value = v.get_secret_value() + else: + value = v + # Check for default/weak secrets - weak_secrets = ["my-test-key", "my-test-salt", "changeme", "secret", "password"] # nosec B105 - list of weak defaults to check against - if v.lower() in weak_secrets: - logger.warning(f"🔓 SECURITY WARNING - {field_name}: Default/weak secret detected! Please set a strong, unique value for production.") + weak_secrets = ["my-test-key", "my-test-salt", "changeme", "secret", "password"] + if value.lower() in weak_secrets: + logger.warning(f"🔓 SECURITY WARNING - {field_name}: Default/weak secret detected! " "Please set a strong, unique value for production.") # Check minimum length - if len(v) < 32: # Using hardcoded value since we can't access instance attributes - logger.warning(f"⚠️ SECURITY WARNING - {field_name}: Secret should be at least 32 characters long. Current length: {len(v)}") + if len(value) < 32: + logger.warning(f"⚠️ SECURITY WARNING - {field_name}: Secret should be at least 32 characters long. " f"Current length: {len(value)}") - # Check entropy (basic check for randomness) - if len(set(v)) < 10: # Less than 10 unique characters + # Basic entropy check (at least 10 unique characters) + if len(set(value)) < 10: logger.warning(f"🔑 SECURITY WARNING - {field_name}: Secret has low entropy. Consider using a more random value.") - return v + # Always return SecretStr to keep it secret-safe + return v if isinstance(v, SecretStr) else SecretStr(value) @field_validator("basic_auth_password") @classmethod @@ -355,8 +379,11 @@ def validate_admin_password(cls, v: str) -> str: if v == "changeme": # nosec B105 - checking for default value logger.warning("🔓 SECURITY WARNING: Default admin password detected! Please change the BASIC_AUTH_PASSWORD immediately.") - if len(v) < 12: # Using hardcoded value - logger.warning(f"⚠️ SECURITY WARNING: Admin password should be at least 12 characters long. Current length: {len(v)}") + # Note: We can't access password_min_length here as it's not set yet during validation + # Using default value of 8 to match the field default + min_length = 8 # This matches the default in password_min_length field + if len(v) < min_length: + logger.warning(f"⚠️ SECURITY WARNING: Admin password should be at least {min_length} characters long. Current length: {len(v)}") # Check password complexity has_upper = any(c.isupper() for c in v) @@ -559,8 +586,8 @@ def _parse_allowed_origins(cls, v): return set(v) # Logging - log_level: str = "INFO" - log_format: str = "json" # json or text + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default="INFO", env="LOG_LEVEL") + log_format: Literal["json", "text"] = "json" # json or text log_to_file: bool = False # Enable file logging (default: stdout/stderr only) log_filemode: str = "a+" # append or overwrite log_file: Optional[str] = None # Only used if log_to_file=True @@ -574,6 +601,32 @@ def _parse_allowed_origins(cls, v): # Log Buffer (for in-memory storage in admin UI) log_buffer_size_mb: float = 1.0 # Size of in-memory log buffer in MB + @field_validator("log_level", mode="before") + @classmethod + def validate_log_level(cls, v: str) -> str: + """ + Normalize and validate the log level value. + + Ensures that the input string matches one of the allowed log levels, + case-insensitively. The value is uppercased before validation so that + "debug", "Debug", etc. are all accepted as "DEBUG". + + Args: + v (str): The log level string provided via configuration or environment. + + Returns: + str: The validated and normalized (uppercase) log level. + + Raises: + ValueError: If the provided value is not one of + {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}. + """ + allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + v_up = v.upper() + if v_up not in allowed: + raise ValueError(f"Invalid log_level: {v}") + return v_up + # Transport transport_type: str = "all" # http, ws, sse, all websocket_ping_interval: int = 30 # seconds @@ -586,7 +639,7 @@ def _parse_allowed_origins(cls, v): federation_discovery: bool = False # For federation_peers strip out quotes to ensure we're passing valid JSON via env - federation_peers: Annotated[List[str], NoDecode] = [] + federation_peers: List[HttpUrl] = Field(default_factory=list, env="FEDERATION_PEERS") @field_validator("federation_peers", mode="before") @classmethod @@ -621,20 +674,58 @@ def _parse_federation_peers(cls, v): >>> Settings._parse_federation_peers([]) [] """ + if v is None: + return [] # always return a list + if isinstance(v, str): v = v.strip() - if v[:1] in "\"'" and v[-1:] == v[:1]: + if len(v) > 1 and v[0] in "\"'" and v[-1] == v[0]: v = v[1:-1] try: peers = json.loads(v) except json.JSONDecodeError: peers = [s.strip() for s in v.split(",") if s.strip()] return peers + + # Convert other iterables to list return list(v) federation_timeout: int = 120 # seconds federation_sync_interval: int = 300 # seconds + # SSO + # For sso_issuers strip out quotes to ensure we're passing valid JSON via env + sso_issuers: Optional[list[HttpUrl]] = Field(default=None, env="SSO_ISSUERS") + + @field_validator("sso_issuers", mode="before") + @classmethod + def parse_issuers(cls, v): + """ + Parse and validate the SSO issuers configuration value. + + Accepts either a JSON array string (e.g. '["https://idp1.com", "https://idp2.com"]') + or an already-parsed list of issuer URLs. This allows environment variables to + provide issuers as JSON while still supporting direct list assignment in code. + + Args: + v (str | list): The input value for SSO issuers, either a JSON array string + or a Python list. + + Returns: + list: A list of issuer URLs. + + Raises: + ValueError: If the string input cannot be parsed as JSON. + """ + + # Accept either a JSON array string or actual list + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + raise ValueError(f"SSO_ISSUERS must be a JSON array of URLs, got: {v!r}") + return v + # Resources resource_cache_size: int = 1000 resource_cache_ttl: int = 3600 # seconds @@ -683,7 +774,7 @@ def _parse_federation_peers(cls, v): db_retry_interval_ms: int = 2000 # Cache - cache_type: str = "database" # memory or redis or database + cache_type: Literal["redis", "memory", "none", "database"] = "database" # memory or redis or database redis_url: Optional[str] = "redis://localhost:6379/0" cache_prefix: str = "mcpgw:" session_ttl: int = 3600 @@ -1146,6 +1237,20 @@ def __init__(self, **kwargs): # Masking value for all sensitive data masked_auth_value: str = "*****" + def log_summary(self): + """ + Log a summary of the application settings. + + Dumps the current settings to a dictionary while excluding sensitive + information such as `database_url` and `memcached_url`, and logs it + at the INFO level. + + This method is useful for debugging or auditing purposes without + exposing credentials or secrets in logs. + """ + summary = self.model_dump(exclude={"database_url", "memcached_url"}) + logger.info(f"Application settings summary: {summary}") + def extract_using_jq(data, jq_filter=""): """ @@ -1294,5 +1399,25 @@ def get_settings() -> Settings: return cfg +def generate_settings_schema() -> dict: + """ + Return the JSON Schema describing the Settings model. + + This schema can be used for validation or documentation purposes. + + Returns: + dict: A dictionary representing the JSON Schema of the Settings model. + """ + return Settings.model_json_schema(mode="validation") + + # Create settings instance settings = get_settings() + + +if __name__ == "__main__": + if "--schema" in sys.argv: + schema = generate_settings_schema() + print(json.dumps(schema, indent=2)) + sys.exit(0) + settings.log_summary() diff --git a/mcpgateway/scripts/validate_env.py b/mcpgateway/scripts/validate_env.py new file mode 100644 index 000000000..d56efaeb4 --- /dev/null +++ b/mcpgateway/scripts/validate_env.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/scripts/validate_env.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Mihai Criveti + +Environment configuration validation script. +This module provides validation for MCP Gateway environment configuration files, +including security checks for weak passwords, default secrets, and invalid settings. + +Usage: + python -m mcpgateway.scripts.validate_env [env_file] + +Examples: + python -m mcpgateway.scripts.validate_env .env.production + python -m mcpgateway.scripts.validate_env # validates .env +""" + +# Standard +import logging +import re +import string +import sys +from typing import Optional + +# Third-Party +from pydantic import SecretStr, ValidationError + +# First-Party +from mcpgateway.config import Settings + + +def get_security_warnings(settings: Settings) -> list[str]: + """ + Inspect a Settings object for weak/default secrets, misconfigurations, and potential security risks. + + Checks include: + - PORT validity + - Weak/default admin and basic auth passwords + - JWT_SECRET_KEY and AUTH_ENCRYPTION_SECRET strength + - URL validity + + Args: + settings (Settings): The application settings to validate. + + Returns: + list[str]: List of warning messages. Empty if no warnings are found. + """ + warnings: list[str] = [] + + # --- Port check --- + if not (1 <= settings.port <= 65535): + warnings.append(f"PORT: Out of allowed range (1-65535). Got: {settings.port}") + + # --- PLATFORM_ADMIN_PASSWORD --- + pw = settings.platform_admin_password + if not pw or pw.lower() in ("changeme", "admin", "password"): + warnings.append("Default admin password detected! Please change PLATFORM_ADMIN_PASSWORD immediately.") + min_length = settings.password_min_length + if len(pw) < min_length: + warnings.append(f"Admin password should be at least {min_length} characters long. Current length: {len(pw)}") + complexity_count = sum([any(c.isupper() for c in pw), any(c.islower() for c in pw), any(c.isdigit() for c in pw), any(c in string.punctuation for c in pw)]) + if complexity_count < 3: + warnings.append("Admin password has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters") + + # --- BASIC_AUTH_PASSWORD --- + basic_pw = settings.basic_auth_password + if not basic_pw or basic_pw.lower() in ("changeme", "password"): + warnings.append("Default BASIC_AUTH_PASSWORD detected! Please change it immediately.") + min_length = settings.password_min_length + if len(basic_pw) < min_length: + warnings.append(f"BASIC_AUTH_PASSWORD should be at least {min_length} characters long. Current length: {len(basic_pw)}") + complexity_count = sum([any(c.isupper() for c in basic_pw), any(c.islower() for c in basic_pw), any(c.isdigit() for c in basic_pw), any(c in string.punctuation for c in basic_pw)]) + if complexity_count < 3: + warnings.append("BASIC_AUTH_PASSWORD has low complexity. Should contain at least 3 of: uppercase, lowercase, digits, special characters") + + # --- JWT_SECRET_KEY --- + jwt = settings.jwt_secret_key.get_secret_value() if isinstance(settings.jwt_secret_key, SecretStr) else settings.jwt_secret_key + weak_jwt = ["my-test-key", "changeme", "secret", "password"] + if jwt.lower() in weak_jwt: + warnings.append("JWT_SECRET_KEY: Default/weak secret detected! Please set a strong, unique value for production.") + if len(jwt) < 32: + warnings.append(f"JWT_SECRET_KEY: Secret should be at least 32 characters long. Current length: {len(jwt)}") + if len(set(jwt)) < 10: + warnings.append("JWT_SECRET_KEY: Secret has low entropy. Consider using a more random value.") + + # --- AUTH_ENCRYPTION_SECRET --- + auth_secret = settings.auth_encryption_secret.get_secret_value() if isinstance(settings.auth_encryption_secret, SecretStr) else settings.auth_encryption_secret + weak_auth = ["my-test-salt", "changeme", "secret", "password"] + if auth_secret.lower() in weak_auth: + warnings.append("AUTH_ENCRYPTION_SECRET: Default/weak secret detected! Please set a strong, unique value for production.") + if len(auth_secret) < 32: + warnings.append(f"AUTH_ENCRYPTION_SECRET: Secret should be at least 32 characters long. Current length: {len(auth_secret)}") + if len(set(auth_secret)) < 10: + warnings.append("AUTH_ENCRYPTION_SECRET: Secret has low entropy. Consider using a more random value.") + + # --- URL Checks --- + url_fields = [("APP_DOMAIN", settings.app_domain)] + for name, val in url_fields: + val_str = str(val) + if not re.match(r"^https?://", val_str): + warnings.append(f"{name}: Should be a valid HTTP or HTTPS URL. Got: {val_str}") + + return warnings + + +def main(env_file: Optional[str] = None, exit_on_warnings: bool = True) -> int: + """ + Validate the application environment configuration. + + Loads settings from the given .env file (or system environment) and checks + for security issues and invalid configurations. + + Behavior: + - Warnings are printed for any weak/default secrets. + - In production, returns exit code 1 if warnings exist. + - In non-production, returns 0 even if warnings exist, unless overridden by `exit_on_warnings`. + - Returns 1 if settings are invalid (ValidationError). + + Args: + env_file (Optional[str]): Path to the .env file. Defaults to None. + exit_on_warnings (bool): If True, exit code 1 will be returned when warnings are detected in any environment. + + Returns: + int: 0 if validation passes, 1 if validation fails (in prod or if invalid). + """ + logging.getLogger("mcpgateway.config").setLevel(logging.ERROR) + + try: + settings = Settings(_env_file=env_file) + except ValidationError as e: + print("❌ Invalid configuration:", e, file=sys.stderr) + return 1 + + warnings = get_security_warnings(settings) + is_prod = settings.environment.lower() == "production" + + if warnings: + for w in warnings: + print(f"⚠️ {w}") + if is_prod or exit_on_warnings: + return 1 + else: + print("⚠️ Warnings detected, but continuing in non-production environment.") + + else: + print("✅ .env validated successfully with no warnings.") + + return 0 + + +if __name__ == "__main__": # pragma: no cover + env_file_path = sys.argv[1] if len(sys.argv) > 1 else None + sys.exit(main(env_file_path)) diff --git a/mcpgateway/services/sso_service.py b/mcpgateway/services/sso_service.py index 50ada0657..87f62d8bb 100644 --- a/mcpgateway/services/sso_service.py +++ b/mcpgateway/services/sso_service.py @@ -26,6 +26,7 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import httpx +from pydantic import SecretStr from sqlalchemy import and_, select from sqlalchemy.orm import Session @@ -73,13 +74,19 @@ def _get_or_create_encryption_key(self) -> bytes: """ # Use the same encryption secret as the auth service key = settings.auth_encryption_secret + if not key: # Generate a new key - in production, this should be persisted key = Fernet.generate_key() # Derive a proper Fernet key from the secret + # Unwrap SecretStr if necessary + if isinstance(key, SecretStr): + key = key.get_secret_value() + + # Convert string to bytes if isinstance(key, str): - key = key.encode() + key = key.encode("utf-8") # Derive a 32-byte key using PBKDF2 kdf = PBKDF2HMAC( diff --git a/mcpgateway/utils/services_auth.py b/mcpgateway/utils/services_auth.py index cf99bf8ea..1d18d05e7 100644 --- a/mcpgateway/utils/services_auth.py +++ b/mcpgateway/utils/services_auth.py @@ -40,6 +40,7 @@ # Third-Party from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from pydantic import SecretStr # First-Party from mcpgateway.config import settings @@ -73,6 +74,11 @@ def get_key() -> bytes: passphrase = settings.auth_encryption_secret if not passphrase: raise ValueError("AUTH_ENCRYPTION_SECRET not set in environment.") + + # If it's SecretStr, extract the real value + if isinstance(passphrase, SecretStr): + passphrase = passphrase.get_secret_value() + return hashlib.sha256(passphrase.encode()).digest() # 32-byte key diff --git a/mcpgateway/validation/jsonrpc.py b/mcpgateway/validation/jsonrpc.py index 63343e3f7..4c66b5b1b 100644 --- a/mcpgateway/validation/jsonrpc.py +++ b/mcpgateway/validation/jsonrpc.py @@ -93,13 +93,13 @@ def to_dict(self) -> Dict[str, Any]: # Standard JSON-RPC error codes -PARSE_ERROR = -32700 # Invalid JSON -INVALID_REQUEST = -32600 # Invalid Request object -METHOD_NOT_FOUND = -32601 # Method not found -INVALID_PARAMS = -32602 # Invalid method parameters -INTERNAL_ERROR = -32603 # Internal JSON-RPC error -SERVER_ERROR_START = -32000 # Start of server error codes -SERVER_ERROR_END = -32099 # End of server error codes +PARSE_ERROR = -32700 #: Invalid JSON +INVALID_REQUEST = -32600 #: Invalid Request object +METHOD_NOT_FOUND = -32601 #: Method not found +INVALID_PARAMS = -32602 #: Invalid method parameters +INTERNAL_ERROR = -32603 #: Internal JSON-RPC error +SERVER_ERROR_START = -32000 #: Start of server error codes +SERVER_ERROR_END = -32099 #: End of server error codes def validate_request(request: Dict[str, Any]) -> None: diff --git a/tests/e2e/test_main_apis.py b/tests/e2e/test_main_apis.py index c932290b6..9cd7158dd 100644 --- a/tests/e2e/test_main_apis.py +++ b/tests/e2e/test_main_apis.py @@ -121,7 +121,7 @@ def generate_test_jwt(): # Helper function for generating test JWT def _generate_test_jwt(): payload = {"sub": "test_user", "exp": int(time.time()) + 3600} - secret = settings.jwt_secret_key + secret = settings.jwt_secret_key.get_secret_value() algorithm = settings.jwt_algorithm token = jwt.encode(payload, secret, algorithm=algorithm) # if isinstance(token, bytes): @@ -325,6 +325,9 @@ def teardown_class(cls): settings.docs_allow_basic_auth = cls._original_docs_allow_basic_auth async def test_docs_with_basic_auth(self, client: AsyncClient): + # Ensure Basic Auth for docs is allowed + settings.docs_allow_basic_auth = True + """Test /docs endpoint with Basic Auth (should return 200 if credentials are valid).""" headers = basic_auth_header("admin", "changeme") response = await client.get("/docs", headers=headers) @@ -332,6 +335,9 @@ async def test_docs_with_basic_auth(self, client: AsyncClient): async def test_redoc_with_basic_auth(self, client: AsyncClient): """Test /redoc endpoint with Basic Auth (should return 200 if credentials are valid).""" + # Ensure Basic Auth for docs is allowed + settings.docs_allow_basic_auth = True + headers = basic_auth_header("admin", "changeme") response = await client.get("/redoc", headers=headers) assert response.status_code == 200 diff --git a/tests/security/test_input_validation.py b/tests/security/test_input_validation.py index a3c2f8652..78dc36027 100644 --- a/tests/security/test_input_validation.py +++ b/tests/security/test_input_validation.py @@ -30,7 +30,7 @@ from unittest.mock import patch # Third-Party -from pydantic import ValidationError +from pydantic import ValidationError, SecretStr import pytest # First-Party @@ -530,7 +530,7 @@ def test_tool_create_auth_assembly(self): logger.debug("Testing tool authentication assembly") # Basic auth - basic_data = {"name": self.VALID_TOOL_NAME, "url": self.VALID_URL, "auth_type": "basic", "auth_username": "user", "auth_password": "pass"} + basic_data = {"name": self.VALID_TOOL_NAME, "url": self.VALID_URL, "auth_type": "basic", "auth_username": "user", "auth_password": SecretStr("pass")} tool = ToolCreate(**basic_data) assert tool.auth.auth_type == "basic" assert tool.auth.auth_value is not None @@ -1079,7 +1079,7 @@ def test_authentication_bypass_attempts(self): logger.debug(f"Testing SQL injection in auth: {payload}") # Auth fields might not be validated as strictly try: - tool = ToolCreate(name=self.VALID_TOOL_NAME, url=self.VALID_URL, auth_type="basic", auth_username=payload, auth_password="password") + tool = ToolCreate(name=self.VALID_TOOL_NAME, url=self.VALID_URL, auth_type="basic", auth_username=payload, auth_password=SecretStr("password")) # If it passes, auth was assembled assert tool.auth is not None except ValidationError as e: @@ -1089,7 +1089,7 @@ def test_authentication_bypass_attempts(self): for payload in self.LDAP_INJECTION_PAYLOADS[:3]: logger.debug(f"Testing LDAP injection in gateway auth: {payload}") try: - gateway = GatewayCreate(name="gateway", url=self.VALID_URL, auth_type="basic", auth_username=payload, auth_password="password") + gateway = GatewayCreate(name="gateway", url=self.VALID_URL, auth_type="basic", auth_username=payload, auth_password=SecretStr("password")) assert gateway.auth_value is not None except ValidationError as e: logger.debug(f"Gateway auth validation error: {e}") diff --git a/tests/unit/mcpgateway/services/test_import_service.py b/tests/unit/mcpgateway/services/test_import_service.py index d503bf244..cad7aa124 100644 --- a/tests/unit/mcpgateway/services/test_import_service.py +++ b/tests/unit/mcpgateway/services/test_import_service.py @@ -346,7 +346,7 @@ async def test_rekey_auth_data_success(import_service): from mcpgateway.utils.services_auth import encode_auth # Store original secret - original_secret = settings.auth_encryption_secret + original_secret = settings.auth_encryption_secret.get_secret_value() try: # Create entity with auth data using a specific secret diff --git a/tests/unit/mcpgateway/test_cli_config_schema.py b/tests/unit/mcpgateway/test_cli_config_schema.py new file mode 100644 index 000000000..9179c79ee --- /dev/null +++ b/tests/unit/mcpgateway/test_cli_config_schema.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +import json +import subprocess +from pathlib import Path + + +def test_config_schema_prints_json(): + """Schema command should emit valid JSON when no output file is given.""" + result = subprocess.run( + ["python", "-m", "mcpgateway.cli", "--config-schema"], + capture_output=True, + text=True, + check=True + ) + + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "title" in data + assert "properties" in data + + +def test_config_schema_writes_to_file(tmp_path: Path): + """Schema command should write to a file when --output is given.""" + out_file = tmp_path / "schema.json" + + subprocess.run( + ["python", "-m", "mcpgateway.cli", "--config-schema", str(out_file)], + check=True + ) + + assert out_file.exists() + data = json.loads(out_file.read_text()) + assert "title" in data + assert "properties" in data diff --git a/tests/unit/mcpgateway/test_config.py b/tests/unit/mcpgateway/test_config.py index 6771d2233..f0e99880b 100644 --- a/tests/unit/mcpgateway/test_config.py +++ b/tests/unit/mcpgateway/test_config.py @@ -48,8 +48,8 @@ def test_parse_federation_peers_json_and_csv(): s_json = Settings(federation_peers=peers_json) s_csv = Settings(federation_peers=peers_csv) - assert s_json.federation_peers == ["https://gw1", "https://gw2"] - assert s_csv.federation_peers == ["https://gw3", "https://gw4"] + assert [str(u) for u in s_json.federation_peers] == ["https://gw1/", "https://gw2/"] + assert [str(u) for u in s_csv.federation_peers] == ["https://gw3/", "https://gw4/"] # --------------------------------------------------------------------------- # @@ -186,7 +186,14 @@ def test_get_settings_is_lru_cached(mock_settings): # Keep the user-supplied baseline # # --------------------------------------------------------------------------- # def test_settings_default_values(): - with patch.dict(os.environ, {}, clear=True): + + dummy_env = { + "JWT_SECRET_KEY": "x" * 32, # required, at least 32 chars + "AUTH_ENCRYPTION_SECRET": "dummy-secret", + "APP_DOMAIN": "http://localhost" + } + + with patch.dict(os.environ, dummy_env, clear=True): settings = Settings(_env_file=None) assert settings.app_name == "MCP_Gateway" @@ -196,6 +203,9 @@ def test_settings_default_values(): assert settings.basic_auth_user == "admin" assert settings.basic_auth_password == "changeme" assert settings.auth_required is True + assert settings.jwt_secret_key.get_secret_value() == "x" * 32 + assert settings.auth_encryption_secret.get_secret_value() == "dummy-secret" + assert str(settings.app_domain) == "http://localhost/" def test_api_key_property(): diff --git a/tests/unit/mcpgateway/test_settings_fields.py b/tests/unit/mcpgateway/test_settings_fields.py new file mode 100644 index 000000000..51a7cf9f1 --- /dev/null +++ b/tests/unit/mcpgateway/test_settings_fields.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +import pytest +from pydantic import ValidationError +from mcpgateway.config import Settings + + +@pytest.mark.parametrize("url", ["http://ok.com/", "https://secure.org/"]) +def test_app_domain_valid(url): + settings = Settings(app_domain=url) + assert str(settings.app_domain) == url + + +@pytest.mark.parametrize("url", ["not-a-url", "ftp://unsupported"]) +def test_app_domain_invalid(url): + with pytest.raises(ValidationError): + Settings(app_domain=url) + + +@pytest.mark.parametrize("level", ["info", "debug", "warning"]) +def test_log_level_valid(level): + settings = Settings(log_level=level) + assert str(settings.log_level) == level.upper() + + +@pytest.mark.parametrize("level", ["verbose", "none"]) +def test_log_level_invalid(level): + with pytest.raises(ValidationError): + Settings(log_level=level) + + +@pytest.mark.parametrize("port", [1, 8080, 65535]) +def test_port_valid(port): + settings = Settings(port=port) + assert settings.port == port + + +@pytest.mark.parametrize("port", [0, -1, 70000]) +def test_port_invalid(port): + with pytest.raises(ValidationError): + Settings(port=port) diff --git a/tests/unit/mcpgateway/test_validate_env.py b/tests/unit/mcpgateway/test_validate_env.py new file mode 100644 index 000000000..0826b4376 --- /dev/null +++ b/tests/unit/mcpgateway/test_validate_env.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# File: tests/unit/mcpgateway/test_validate_env.py +from pathlib import Path +import pytest +import logging +import os +from unittest.mock import patch + +# Suppress mcpgateway.config logs during tests +logging.getLogger("mcpgateway.config").setLevel(logging.ERROR) + +# Import the validate_env script directly +from mcpgateway.scripts import validate_env as ve + + +@pytest.fixture +def valid_env(tmp_path: Path): + envfile = tmp_path / ".env" + envfile.write_text( + "APP_DOMAIN=http://localhost:8000\n" + "PORT=8080\n" + "LOG_LEVEL=info\n" + "PLATFORM_ADMIN_PASSWORD=V7g!3Rf$Tz9&Lp2@Kq1Xh5Jm8Nc0YsR4\n" + "BASIC_AUTH_USER=admin\n" + "BASIC_AUTH_PASSWORD=V9r$2Tx!Bf8&kZq@3LpC#7Jm6Nh1UoR0\n" + "JWT_SECRET_KEY=Z9x!3Tp#Rk8&Vm4Yq$2Lf6Jb0Nw1AoS5DdGh7KuCvBzPmY\n" + "AUTH_ENCRYPTION_SECRET=Q2w@8Er#Tz5&Ui6Oy$1Lp0Kb7Nh3Xc9Vj4AmF2GsYmCvBnD\n" + ) + return envfile + + +@pytest.fixture +def invalid_env(tmp_path: Path): + envfile = tmp_path / ".env" + # Invalid URL + wrong log level + invalid port + envfile.write_text( + "APP_DOMAIN=not-a-url\n" + "PORT=-1\n" + "LOG_LEVEL=wronglevel\n" + ) + return envfile + + +def test_validate_env_success_direct(valid_env: Path): + """ + Test a valid .env. Warnings will be printed but do NOT fail the test. + """ + # Clear any cached settings to ensure test isolation + from mcpgateway.config import get_settings + get_settings.cache_clear() + + # Clear environment variables that might interfere + env_vars_to_clear = ['APP_DOMAIN', 'PORT', 'LOG_LEVEL', 'PLATFORM_ADMIN_PASSWORD', + 'BASIC_AUTH_PASSWORD', 'JWT_SECRET_KEY', 'AUTH_ENCRYPTION_SECRET'] + + with patch.dict(os.environ, {}, clear=False): + for var in env_vars_to_clear: + os.environ.pop(var, None) + + code = ve.main(env_file=str(valid_env), exit_on_warnings=False) + assert code == 0 + + +def test_validate_env_failure_direct(invalid_env: Path): + """ + Test an invalid .env. Should fail due to ValidationError. + """ + # Clear any cached settings to ensure test isolation + from mcpgateway.config import get_settings + get_settings.cache_clear() + + # Clear environment variables that might interfere + env_vars_to_clear = ['APP_DOMAIN', 'PORT', 'LOG_LEVEL', 'PLATFORM_ADMIN_PASSWORD', + 'BASIC_AUTH_PASSWORD', 'JWT_SECRET_KEY', 'AUTH_ENCRYPTION_SECRET'] + + with patch.dict(os.environ, {}, clear=False): + for var in env_vars_to_clear: + os.environ.pop(var, None) + + print("Invalid env path:", invalid_env) + code = ve.main(env_file=str(invalid_env), exit_on_warnings=False) + print("Returned code:", code) + assert code != 0 diff --git a/tests/unit/mcpgateway/utils/test_verify_credentials.py b/tests/unit/mcpgateway/utils/test_verify_credentials.py index 52c0d2fa9..0d8b823a8 100644 --- a/tests/unit/mcpgateway/utils/test_verify_credentials.py +++ b/tests/unit/mcpgateway/utils/test_verify_credentials.py @@ -349,6 +349,8 @@ async def test_docs_invalid_basic_auth_fails(monkeypatch): async def test_integration_docs_endpoint_both_auth_methods(test_client, monkeypatch): """Integration test: /docs accepts both auth methods when enabled.""" monkeypatch.setattr("mcpgateway.config.settings.docs_allow_basic_auth", True) + monkeypatch.setattr("mcpgateway.config.settings.basic_auth_user", "admin") + monkeypatch.setattr("mcpgateway.config.settings.basic_auth_password", "changeme") monkeypatch.setattr("mcpgateway.config.settings.jwt_secret_key", SECRET) monkeypatch.setattr("mcpgateway.config.settings.jwt_algorithm", ALGO) monkeypatch.setattr("mcpgateway.config.settings.jwt_audience", "mcpgateway-api")