mcp-proxy-adapter 6.3.28__py3-none-any.whl → 6.3.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,280 +1,218 @@
1
1
  """
2
- Configuration validator for strict validation before startup.
2
+ Configuration validation utilities.
3
3
 
4
- This module provides strict validation of configuration to prevent startup
5
- with invalid or insecure configurations.
4
+ Author: Vasiliy Zdanovskiy
5
+ email: vasilyvz@gmail.com
6
+
7
+ This module provides strict configuration validation with support for two modes:
8
+ - startup (strict): fail-fast with SystemExit on invalid config
9
+ - reload (soft): log errors and keep previous config
6
10
  """
7
11
 
12
+ from __future__ import annotations
13
+
8
14
  import os
9
- import json
10
- import uuid
11
- from typing import Dict, Any, List
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
12
18
 
13
19
  from mcp_proxy_adapter.core.logging import logger
14
20
 
15
21
 
22
+ @dataclass
23
+ class ConfigValidationResult:
24
+ is_valid: bool
25
+ errors: List[str]
26
+ warnings: List[str]
27
+
28
+ @staticmethod
29
+ def ok() -> "ConfigValidationResult":
30
+ return ConfigValidationResult(True, [], [])
31
+
32
+
16
33
  class ConfigValidator:
17
34
  """
18
- Strict configuration validator.
19
-
20
- Validates configuration before startup and prevents startup
21
- with invalid or insecure configurations.
35
+ Unified configuration validator supporting both result-based and boolean APIs.
22
36
  """
23
37
 
24
- def __init__(self, config: Dict[str, Any]):
25
- """
26
- Initialize configuration validator.
27
-
28
- Args:
29
- config: Configuration dictionary to validate
30
- """
31
- self.config = config
38
+ def __init__(self, _config: Optional[Dict[str, Any]] = None) -> None:
39
+ # Optional stored config for validate_all() compatibility
40
+ self._config: Optional[Dict[str, Any]] = _config
32
41
  self.errors: List[str] = []
33
42
  self.warnings: List[str] = []
34
43
 
35
- def validate_all(self) -> bool:
44
+ def validate(self, config: Optional[Dict[str, Any]] = None) -> ConfigValidationResult:
36
45
  """
37
- Validate all configuration sections.
46
+ Validate configuration and return detailed result.
38
47
 
39
- Returns:
40
- True if configuration is valid, False otherwise
48
+ Args:
49
+ config: Configuration dict. If None, uses self._config.
41
50
  """
42
- self.errors.clear()
43
- self.warnings.clear()
44
-
45
- # Validate basic structure
46
- if not self._validate_basic_structure():
47
- return False
48
-
49
- # Validate UUID configuration (mandatory)
50
- if not self._validate_uuid_config():
51
- return False
52
-
53
- # Validate server configuration
54
- if not self._validate_server_config():
55
- return False
56
-
57
- # Validate security configuration
58
- if not self._validate_security_config():
59
- return False
60
-
61
- # Validate commands configuration
62
- if not self._validate_commands_config():
63
- return False
64
-
65
- # Validate SSL configuration
66
- if not self._validate_ssl_config():
67
- return False
68
-
69
- # Validate roles configuration
70
- if not self._validate_roles_config():
71
- return False
72
-
73
- # Log warnings if any
74
- if self.warnings:
75
- for warning in self.warnings:
76
- logger.warning(f"Configuration warning: {warning}")
77
-
78
- # Return success only if no errors
79
- return len(self.errors) == 0
80
-
81
- def _validate_basic_structure(self) -> bool:
82
- """Validate basic configuration structure."""
83
- required_sections = ["server", "logging", "commands"]
51
+ cfg = config if config is not None else (self._config or {})
52
+ self.errors = []
53
+ self.warnings = []
54
+
55
+ # Top-level basics
56
+ self._validate_uuid(cfg)
57
+ self._validate_server(cfg)
58
+ self._validate_protocols(cfg)
59
+
60
+ # SSL/mTLS
61
+ self._validate_ssl(cfg)
62
+
63
+ # Security framework sections
64
+ self._validate_security_auth(cfg)
65
+ self._validate_permissions(cfg)
66
+ self._validate_rate_limit(cfg)
67
+
68
+ return ConfigValidationResult(
69
+ is_valid=len(self.errors) == 0,
70
+ errors=list(self.errors),
71
+ warnings=list(self.warnings),
72
+ )
73
+
74
+ # Boolean API for backward compatibility (used in main.py)
75
+ def validate_all(self) -> bool:
76
+ result = self.validate(self._config)
77
+ return result.is_valid
84
78
 
85
- for section in required_sections:
86
- if section not in self.config:
87
- self.errors.append(f"Missing required configuration section: {section}")
79
+ def get_errors(self) -> List[str]:
80
+ return list(self.errors)
88
81
 
89
- return len(self.errors) == 0
82
+ def get_warnings(self) -> List[str]:
83
+ return list(self.warnings)
90
84
 
91
- def _validate_uuid_config(self) -> bool:
92
- """Validate UUID configuration (mandatory parameter)."""
93
- # Check if UUID is present in root config
94
- service_uuid = self.config.get("uuid")
85
+ # ------------- Section validators -------------
95
86
 
96
- if not service_uuid:
87
+ def _validate_uuid(self, config: Dict[str, Any]) -> None:
88
+ uuid_value = config.get("uuid")
89
+ if not uuid_value or not isinstance(uuid_value, str) or len(uuid_value) < 8:
97
90
  self.errors.append(
98
91
  "UUID is required in configuration. Add 'uuid' field with a valid UUID4 value."
99
92
  )
100
- return False
101
-
102
- # Validate UUID format
103
- try:
104
- # Try to parse as UUID
105
- parsed_uuid = uuid.UUID(service_uuid)
106
-
107
- # Check if it's UUID4 (version 4)
108
- if parsed_uuid.version != 4:
109
- self.errors.append(
110
- f"UUID must be version 4 (UUID4). Current version: {parsed_uuid.version}"
111
- )
112
- return False
113
-
114
- except (ValueError, TypeError) as e:
115
- self.errors.append(f"Invalid UUID format: {service_uuid}. Error: {e}")
116
- return False
117
-
118
- return len(self.errors) == 0
119
93
 
120
- def _validate_server_config(self) -> bool:
121
- """Validate server configuration."""
122
- server_config = self.config.get("server", {})
123
-
124
- # Validate host
125
- host = server_config.get("host")
126
- if not host:
127
- self.errors.append("Server host is required")
128
-
129
- # Validate port
130
- port = server_config.get("port")
131
- if not isinstance(port, int) or port < 1 or port > 65535:
132
- self.errors.append("Server port must be an integer between 1 and 65535")
133
-
134
- return len(self.errors) == 0
135
-
136
- def _validate_security_config(self) -> bool:
137
- """Validate security configuration."""
138
- security_config = self.config.get("security", {})
139
-
140
- # Check if security is enabled
141
- security_enabled = security_config.get("enabled", True)
142
- auth_enabled = self.config.get("auth_enabled", False)
94
+ def _validate_server(self, config: Dict[str, Any]) -> None:
95
+ server = config.get("server", {})
96
+ port = server.get("port")
97
+ if port is None or not isinstance(port, int) or not (1 <= port <= 65535):
98
+ self.errors.append("Server.port must be an integer between 1 and 65535")
99
+
100
+ host = server.get("host")
101
+ if not host or not isinstance(host, str):
102
+ self.errors.append("Server.host must be a non-empty string")
103
+
104
+ def _validate_protocols(self, config: Dict[str, Any]) -> None:
105
+ protocols = config.get("protocols", {})
106
+ if not isinstance(protocols, dict):
107
+ self.warnings.append("'protocols' must be a dictionary; ignoring")
108
+ return
109
+
110
+ enabled = protocols.get("enabled", True)
111
+ if not enabled:
112
+ return # Disabled => allow all (manager bypassed)
113
+
114
+ allowed = protocols.get("allowed_protocols", [])
115
+ default_protocol = protocols.get("default_protocol")
116
+ if not isinstance(allowed, list) or not all(isinstance(x, str) for x in allowed):
117
+ self.errors.append("protocols.allowed_protocols must be a list of strings")
118
+ if default_protocol and default_protocol not in allowed:
119
+ self.errors.append(
120
+ "protocols.default_protocol must be present in allowed_protocols"
121
+ )
143
122
 
144
- # Validate permissions configuration
145
- permissions_config = security_config.get("permissions", {})
146
- permissions_enabled = permissions_config.get("enabled", False)
123
+ def _validate_ssl(self, config: Dict[str, Any]) -> None:
124
+ # Prefer security.ssl if enabled; otherwise fallback to root ssl
125
+ sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
126
+ security_ssl = sec.get("ssl", {}) if isinstance(sec.get("ssl"), dict) else {}
127
+ root_ssl = config.get("ssl", {}) if isinstance(config.get("ssl"), dict) else {}
147
128
 
148
- if permissions_enabled:
149
- # Permissions require authentication to identify users
150
- auth_config = security_config.get("auth", {})
151
- if not auth_config.get("enabled", False):
152
- self.errors.append(
153
- "Permissions are enabled but authentication is disabled. Permissions require authentication to identify users."
154
- )
155
- return False
129
+ effective_ssl = security_ssl if security_ssl.get("enabled", False) else root_ssl
130
+ if not isinstance(effective_ssl, dict):
131
+ return
156
132
 
157
- # Check if there are any authentication methods available
158
- auth_methods = auth_config.get("methods", [])
159
- if not auth_methods:
133
+ if effective_ssl.get("enabled", False):
134
+ cert_file = effective_ssl.get("cert_file")
135
+ key_file = effective_ssl.get("key_file")
136
+ if not cert_file or not key_file:
160
137
  self.errors.append(
161
- "Permissions are enabled but no authentication methods are configured. At least one authentication method is required."
138
+ "SSL enabled but 'cert_file' or 'key_file' is missing"
162
139
  )
163
- return False
164
-
165
- if security_enabled and auth_enabled:
166
- # Validate auth configuration
167
- auth_config = security_config.get("auth", {})
168
- if not auth_config.get("enabled", False):
169
- self.errors.append("Security is enabled but auth is disabled")
170
- return False
171
-
172
- # Validate API keys if auth is enabled
173
- if auth_config.get("enabled", False):
174
- api_keys = auth_config.get("api_keys", {})
175
- if not api_keys:
140
+ else:
141
+ if not Path(cert_file).exists():
142
+ self.errors.append(f"SSL certificate file not found: {cert_file}")
143
+ if not Path(key_file).exists():
144
+ self.errors.append(f"SSL private key file not found: {key_file}")
145
+
146
+ # mTLS
147
+ verify_client = effective_ssl.get("verify_client", False)
148
+ ca_candidates = [
149
+ effective_ssl.get("ca_cert_file"), # security.ssl
150
+ effective_ssl.get("ca_cert"), # legacy root.ssl
151
+ ]
152
+ ca_cert = next((c for c in ca_candidates if isinstance(c, str) and c), None)
153
+
154
+ if verify_client:
155
+ if not ca_cert:
176
156
  self.errors.append(
177
- "API keys are required when authentication is enabled"
157
+ "mTLS requires a CA certificate (security.ssl.ca_cert_file or ssl.ca_cert)"
178
158
  )
179
- return False
180
-
181
- # Validate API key format
182
- for key, value in api_keys.items():
183
- if not key or not value:
184
- self.errors.append("API keys must have non-empty key and value")
185
- return False
186
-
187
- return len(self.errors) == 0
159
+ elif not Path(ca_cert).exists():
160
+ self.errors.append(f"CA certificate file not found: {ca_cert}")
188
161
 
189
- def _validate_commands_config(self) -> bool:
190
- """Validate commands configuration."""
191
- commands_config = self.config.get("commands", {})
192
-
193
- # Validate commands directory if auto_discovery is enabled
194
- if commands_config.get("auto_discovery", True):
195
- commands_dir = commands_config.get("commands_directory", "./commands")
196
- if not os.path.exists(commands_dir):
162
+ # TLS versions
163
+ min_tls = str(effective_ssl.get("min_tls_version", "TLSv1.2"))
164
+ valid_min = {"TLSv1.2", "1.2", "TLSv1.3", "1.3"}
165
+ if min_tls not in valid_min:
197
166
  self.warnings.append(
198
- f"Commands directory does not exist: {commands_dir}"
167
+ f"Unknown min_tls_version '{min_tls}', expected one of {sorted(valid_min)}"
199
168
  )
200
169
 
201
- return True
202
-
203
- def _validate_ssl_config(self) -> bool:
204
- """Validate SSL configuration."""
205
- ssl_config = self.config.get("ssl", {})
206
- ssl_enabled = ssl_config.get("enabled", False)
207
-
208
- if ssl_enabled:
209
- # Validate certificate files
210
- cert_file = ssl_config.get("cert_file")
211
- key_file = ssl_config.get("key_file")
212
-
213
- if not cert_file or not key_file:
214
- self.errors.append(
215
- "SSL certificate and key files are required when SSL is enabled"
216
- )
217
- return False
170
+ # Conflict check
171
+ if security_ssl.get("enabled") and root_ssl.get("enabled"):
172
+ self.warnings.append(
173
+ "SSL configured in both security.ssl and root ssl; security.ssl is preferred"
174
+ )
218
175
 
219
- if not os.path.exists(cert_file):
220
- self.errors.append(f"SSL certificate file not found: {cert_file}")
176
+ def _validate_security_auth(self, config: Dict[str, Any]) -> None:
177
+ sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
178
+ auth = sec.get("auth", {}) if isinstance(sec.get("auth"), dict) else {}
221
179
 
222
- if not os.path.exists(key_file):
223
- self.errors.append(f"SSL private key file not found: {key_file}")
180
+ if not sec.get("enabled", False) or not auth.get("enabled", False):
181
+ return
224
182
 
225
- return len(self.errors) == 0
183
+ methods = auth.get("methods", [])
184
+ if not isinstance(methods, list):
185
+ self.errors.append("security.auth.methods must be a list")
186
+ return
226
187
 
227
- def _validate_roles_config(self) -> bool:
228
- """Validate roles configuration."""
229
- roles_config = self.config.get("roles", {})
230
- roles_enabled = roles_config.get("enabled", False)
188
+ if "jwt" in methods and not auth.get("jwt_secret"):
189
+ self.warnings.append("JWT method enabled but jwt_secret is empty")
231
190
 
232
- if roles_enabled:
233
- config_file = roles_config.get("config_file")
234
- if not config_file:
191
+ if "certificate" in methods:
192
+ # Ensure SSL is enabled for certificate auth
193
+ ssl_enabled = (
194
+ (sec.get("ssl", {}) or {}).get("enabled", False)
195
+ or (config.get("ssl", {}) or {}).get("enabled", False)
196
+ )
197
+ if not ssl_enabled:
235
198
  self.errors.append(
236
- "Roles config file is required when roles are enabled"
199
+ "Certificate auth enabled but SSL is disabled (enable security.ssl or root ssl)"
237
200
  )
238
- return False
239
-
240
- if not os.path.exists(config_file):
241
- self.errors.append(f"Roles config file not found: {config_file}")
242
- return False
243
-
244
- # Validate roles schema file
245
- try:
246
- with open(config_file, "r") as f:
247
- roles_schema = json.load(f)
248
-
249
- if "roles" not in roles_schema:
250
- self.errors.append("Roles config file must contain 'roles' section")
251
- return False
252
201
 
253
- except (json.JSONDecodeError, IOError) as e:
254
- self.errors.append(f"Failed to read roles config file: {e}")
255
- return False
256
-
257
- return len(self.errors) == 0
258
-
259
- def get_errors(self) -> List[str]:
260
- """Get validation errors."""
261
- return self.errors.copy()
262
-
263
- def get_warnings(self) -> List[str]:
264
- """Get validation warnings."""
265
- return self.warnings.copy()
266
-
267
- def print_validation_report(self):
268
- """Print validation report."""
269
- if self.errors:
270
- logger.error("Configuration validation failed:")
271
- for error in self.errors:
272
- logger.error(f" - {error}")
273
-
274
- if self.warnings:
275
- logger.warning("Configuration warnings:")
276
- for warning in self.warnings:
277
- logger.warning(f" - {warning}")
278
-
279
- if not self.errors and not self.warnings:
280
- logger.info("Configuration validation passed")
202
+ def _validate_permissions(self, config: Dict[str, Any]) -> None:
203
+ sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
204
+ perm = sec.get("permissions", {}) if isinstance(sec.get("permissions"), dict) else {}
205
+ roles = perm.get("roles_file")
206
+ if perm.get("enabled", False) and roles:
207
+ if not Path(roles).exists():
208
+ self.errors.append(f"Permissions enabled but roles file not found: {roles}")
209
+
210
+ def _validate_rate_limit(self, config: Dict[str, Any]) -> None:
211
+ sec = config.get("security", {}) if isinstance(config.get("security"), dict) else {}
212
+ rl = sec.get("rate_limit", {}) if isinstance(sec.get("rate_limit"), dict) else {}
213
+ if rl.get("enabled", False):
214
+ rpm = rl.get("default_requests_per_minute") or rl.get("requests_per_minute")
215
+ if rpm is None or not isinstance(rpm, int) or rpm <= 0:
216
+ self.errors.append(
217
+ "Rate limit enabled but requests_per_minute is not a positive integer"
218
+ )