mcp-proxy-adapter 6.3.28__py3-none-any.whl → 6.3.29__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.
- mcp_proxy_adapter/api/app.py +118 -37
- mcp_proxy_adapter/commands/command_registry.py +24 -1
- mcp_proxy_adapter/core/config_validator.py +172 -234
- mcp_proxy_adapter/core/proxy_client.py +163 -640
- mcp_proxy_adapter/core/proxy_registration.py +143 -41
- mcp_proxy_adapter/main.py +57 -22
- {mcp_proxy_adapter-6.3.28.dist-info → mcp_proxy_adapter-6.3.29.dist-info}/METADATA +1 -1
- {mcp_proxy_adapter-6.3.28.dist-info → mcp_proxy_adapter-6.3.29.dist-info}/RECORD +11 -11
- {mcp_proxy_adapter-6.3.28.dist-info → mcp_proxy_adapter-6.3.29.dist-info}/WHEEL +0 -0
- {mcp_proxy_adapter-6.3.28.dist-info → mcp_proxy_adapter-6.3.29.dist-info}/entry_points.txt +0 -0
- {mcp_proxy_adapter-6.3.28.dist-info → mcp_proxy_adapter-6.3.29.dist-info}/top_level.txt +0 -0
@@ -1,280 +1,218 @@
|
|
1
1
|
"""
|
2
|
-
Configuration
|
2
|
+
Configuration validation utilities.
|
3
3
|
|
4
|
-
|
5
|
-
|
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
|
10
|
-
import
|
11
|
-
from typing import Dict,
|
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
|
-
|
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,
|
25
|
-
|
26
|
-
|
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
|
44
|
+
def validate(self, config: Optional[Dict[str, Any]] = None) -> ConfigValidationResult:
|
36
45
|
"""
|
37
|
-
Validate
|
46
|
+
Validate configuration and return detailed result.
|
38
47
|
|
39
|
-
|
40
|
-
|
48
|
+
Args:
|
49
|
+
config: Configuration dict. If None, uses self._config.
|
41
50
|
"""
|
42
|
-
self.
|
43
|
-
self.
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
-
|
86
|
-
|
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
|
-
|
82
|
+
def get_warnings(self) -> List[str]:
|
83
|
+
return list(self.warnings)
|
90
84
|
|
91
|
-
|
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
|
-
|
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
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
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
|
-
|
145
|
-
|
146
|
-
|
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
|
149
|
-
|
150
|
-
|
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
|
-
|
158
|
-
|
159
|
-
|
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
|
-
"
|
138
|
+
"SSL enabled but 'cert_file' or 'key_file' is missing"
|
162
139
|
)
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
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
|
-
"
|
157
|
+
"mTLS requires a CA certificate (security.ssl.ca_cert_file or ssl.ca_cert)"
|
178
158
|
)
|
179
|
-
|
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
|
-
|
190
|
-
|
191
|
-
|
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"
|
167
|
+
f"Unknown min_tls_version '{min_tls}', expected one of {sorted(valid_min)}"
|
199
168
|
)
|
200
169
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
220
|
-
|
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
|
-
|
223
|
-
|
180
|
+
if not sec.get("enabled", False) or not auth.get("enabled", False):
|
181
|
+
return
|
224
182
|
|
225
|
-
|
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
|
-
|
228
|
-
|
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
|
233
|
-
|
234
|
-
|
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
|
-
"
|
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
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
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
|
+
)
|