kubectl-mcp-server 1.15.0__py3-none-any.whl → 1.17.0__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.
Files changed (45) hide show
  1. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/METADATA +34 -13
  2. kubectl_mcp_server-1.17.0.dist-info/RECORD +75 -0
  3. kubectl_mcp_tool/__init__.py +1 -1
  4. kubectl_mcp_tool/cli/cli.py +83 -9
  5. kubectl_mcp_tool/cli/output.py +14 -0
  6. kubectl_mcp_tool/config/__init__.py +46 -0
  7. kubectl_mcp_tool/config/loader.py +386 -0
  8. kubectl_mcp_tool/config/schema.py +184 -0
  9. kubectl_mcp_tool/crd_detector.py +247 -0
  10. kubectl_mcp_tool/k8s_config.py +19 -0
  11. kubectl_mcp_tool/mcp_server.py +246 -8
  12. kubectl_mcp_tool/observability/__init__.py +59 -0
  13. kubectl_mcp_tool/observability/metrics.py +223 -0
  14. kubectl_mcp_tool/observability/stats.py +255 -0
  15. kubectl_mcp_tool/observability/tracing.py +335 -0
  16. kubectl_mcp_tool/prompts/__init__.py +43 -0
  17. kubectl_mcp_tool/prompts/builtin.py +695 -0
  18. kubectl_mcp_tool/prompts/custom.py +298 -0
  19. kubectl_mcp_tool/prompts/prompts.py +180 -4
  20. kubectl_mcp_tool/safety.py +155 -0
  21. kubectl_mcp_tool/tools/__init__.py +20 -0
  22. kubectl_mcp_tool/tools/backup.py +881 -0
  23. kubectl_mcp_tool/tools/capi.py +727 -0
  24. kubectl_mcp_tool/tools/certs.py +709 -0
  25. kubectl_mcp_tool/tools/cilium.py +582 -0
  26. kubectl_mcp_tool/tools/cluster.py +384 -0
  27. kubectl_mcp_tool/tools/gitops.py +552 -0
  28. kubectl_mcp_tool/tools/keda.py +464 -0
  29. kubectl_mcp_tool/tools/kiali.py +652 -0
  30. kubectl_mcp_tool/tools/kubevirt.py +803 -0
  31. kubectl_mcp_tool/tools/policy.py +554 -0
  32. kubectl_mcp_tool/tools/rollouts.py +790 -0
  33. tests/test_browser.py +2 -2
  34. tests/test_config.py +386 -0
  35. tests/test_ecosystem.py +331 -0
  36. tests/test_mcp_integration.py +251 -0
  37. tests/test_observability.py +521 -0
  38. tests/test_prompts.py +716 -0
  39. tests/test_safety.py +218 -0
  40. tests/test_tools.py +70 -8
  41. kubectl_mcp_server-1.15.0.dist-info/RECORD +0 -49
  42. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/WHEEL +0 -0
  43. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/entry_points.txt +0 -0
  44. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/licenses/LICENSE +0 -0
  45. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,386 @@
1
+ """Configuration loader with TOML support, drop-in directories, and SIGHUP reload.
2
+
3
+ This module handles:
4
+ - Loading main config from ~/.config/kubectl-mcp-server/config.toml
5
+ - Merging drop-in configs from ~/.config/kubectl-mcp-server/config.d/*.toml
6
+ - Environment variable overrides
7
+ - SIGHUP signal handling for runtime reloads
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import signal
13
+ import sys
14
+ from dataclasses import fields
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Dict, List, Optional, Union
17
+
18
+ from .schema import (
19
+ BrowserConfig,
20
+ Config,
21
+ KubernetesConfig,
22
+ LoggingConfig,
23
+ MetricsConfig,
24
+ SafetyConfig,
25
+ ServerConfig,
26
+ validate_config,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Try to import tomli/tomllib for TOML parsing
32
+ try:
33
+ import tomllib # Python 3.11+
34
+ except ImportError:
35
+ try:
36
+ import tomli as tomllib # type: ignore
37
+ except ImportError:
38
+ tomllib = None # type: ignore
39
+
40
+ # Global config instance
41
+ _config: Optional[Config] = None
42
+ _config_callbacks: List[Callable[[Config], None]] = []
43
+
44
+
45
+ def get_config_paths() -> Dict[str, Path]:
46
+ """Get standard configuration paths.
47
+
48
+ Returns:
49
+ Dictionary with paths for config_dir, main_config, and drop_in_dir
50
+ """
51
+ # Check XDG_CONFIG_HOME first, then fall back to ~/.config
52
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
53
+ if xdg_config:
54
+ base_dir = Path(xdg_config)
55
+ else:
56
+ base_dir = Path.home() / ".config"
57
+
58
+ config_dir = base_dir / "kubectl-mcp-server"
59
+
60
+ return {
61
+ "config_dir": config_dir,
62
+ "main_config": config_dir / "config.toml",
63
+ "drop_in_dir": config_dir / "config.d",
64
+ }
65
+
66
+
67
+ def _load_toml_file(path: Path) -> Dict[str, Any]:
68
+ """Load a TOML file and return its contents as a dictionary.
69
+
70
+ Args:
71
+ path: Path to the TOML file
72
+
73
+ Returns:
74
+ Dictionary containing the TOML data
75
+
76
+ Raises:
77
+ RuntimeError: If tomllib/tomli is not available
78
+ FileNotFoundError: If the file doesn't exist
79
+ ValueError: If the TOML is invalid
80
+ """
81
+ if tomllib is None:
82
+ raise RuntimeError(
83
+ "TOML parsing requires Python 3.11+ or the 'tomli' package. "
84
+ "Install with: pip install tomli"
85
+ )
86
+
87
+ with open(path, "rb") as f:
88
+ return tomllib.load(f)
89
+
90
+
91
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
92
+ """Deep merge two dictionaries, with override taking precedence.
93
+
94
+ Args:
95
+ base: Base dictionary
96
+ override: Override dictionary (takes precedence)
97
+
98
+ Returns:
99
+ Merged dictionary
100
+ """
101
+ result = base.copy()
102
+
103
+ for key, value in override.items():
104
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
105
+ result[key] = _deep_merge(result[key], value)
106
+ else:
107
+ result[key] = value
108
+
109
+ return result
110
+
111
+
112
+ def _apply_env_overrides(config_dict: Dict[str, Any]) -> Dict[str, Any]:
113
+ """Apply environment variable overrides to configuration.
114
+
115
+ Environment variables follow the pattern:
116
+ MCP_<SECTION>_<KEY>=value
117
+
118
+ Examples:
119
+ MCP_SERVER_PORT=9000
120
+ MCP_SAFETY_MODE=read-only
121
+ MCP_BROWSER_ENABLED=true
122
+
123
+ Args:
124
+ config_dict: Configuration dictionary
125
+
126
+ Returns:
127
+ Configuration with environment overrides applied
128
+ """
129
+ result = config_dict.copy()
130
+
131
+ env_mappings = {
132
+ # Server settings
133
+ "MCP_SERVER_TRANSPORT": ("server", "transport"),
134
+ "MCP_SERVER_HOST": ("server", "host"),
135
+ "MCP_SERVER_PORT": ("server", "port", int),
136
+ "MCP_DEBUG": ("server", "debug", lambda x: x.lower() in ("true", "1", "yes")),
137
+ "MCP_LOG_FILE": ("server", "log_file"),
138
+ "MCP_LOG_LEVEL": ("server", "log_level"),
139
+ # Safety settings
140
+ "MCP_SAFETY_MODE": ("safety", "mode"),
141
+ # Browser settings
142
+ "MCP_BROWSER_ENABLED": ("browser", "enabled", lambda x: x.lower() in ("true", "1", "yes")),
143
+ "MCP_BROWSER_PROVIDER": ("browser", "provider"),
144
+ "MCP_BROWSER_HEADED": ("browser", "headed", lambda x: x.lower() in ("true", "1", "yes")),
145
+ "BROWSERBASE_API_KEY": ("browser", "browserbase_api_key"),
146
+ "BROWSERBASE_PROJECT_ID": ("browser", "browserbase_project_id"),
147
+ "BROWSER_USE_API_KEY": ("browser", "browseruse_api_key"),
148
+ "MCP_BROWSER_CDP_URL": ("browser", "cdp_url"),
149
+ # Metrics settings
150
+ "MCP_METRICS_ENABLED": ("metrics", "enabled", lambda x: x.lower() in ("true", "1", "yes")),
151
+ "MCP_TRACING_ENABLED": ("metrics", "tracing_enabled", lambda x: x.lower() in ("true", "1", "yes")),
152
+ "OTEL_EXPORTER_OTLP_ENDPOINT": ("metrics", "otlp_endpoint"),
153
+ "OTEL_SERVICE_NAME": ("metrics", "service_name"),
154
+ # Kubernetes settings
155
+ "KUBECONFIG": ("kubernetes", "kubeconfig"),
156
+ "MCP_K8S_CONTEXT": ("kubernetes", "context"),
157
+ "MCP_K8S_NAMESPACE": ("kubernetes", "default_namespace"),
158
+ }
159
+
160
+ for env_var, mapping in env_mappings.items():
161
+ value = os.environ.get(env_var)
162
+ if value is not None:
163
+ section = mapping[0]
164
+ key = mapping[1]
165
+ converter = mapping[2] if len(mapping) > 2 else str
166
+
167
+ if section not in result:
168
+ result[section] = {}
169
+
170
+ try:
171
+ result[section][key] = converter(value)
172
+ except (ValueError, TypeError) as e:
173
+ logger.warning(f"Invalid value for {env_var}: {value} ({e})")
174
+
175
+ return result
176
+
177
+
178
+ def _dict_to_config(config_dict: Dict[str, Any]) -> Config:
179
+ """Convert a configuration dictionary to a Config dataclass.
180
+
181
+ Args:
182
+ config_dict: Configuration dictionary
183
+
184
+ Returns:
185
+ Config dataclass instance
186
+ """
187
+
188
+ def make_dataclass(cls, data: Dict[str, Any]):
189
+ """Create a dataclass instance from a dictionary, ignoring extra keys."""
190
+ valid_keys = {f.name for f in fields(cls)}
191
+ filtered = {k: v for k, v in data.items() if k in valid_keys}
192
+ return cls(**filtered)
193
+
194
+ server = make_dataclass(ServerConfig, config_dict.get("server", {}))
195
+ safety = make_dataclass(SafetyConfig, config_dict.get("safety", {}))
196
+ browser = make_dataclass(BrowserConfig, config_dict.get("browser", {}))
197
+ metrics = make_dataclass(MetricsConfig, config_dict.get("metrics", {}))
198
+ logging_config = make_dataclass(LoggingConfig, config_dict.get("logging", {}))
199
+ kubernetes = make_dataclass(KubernetesConfig, config_dict.get("kubernetes", {}))
200
+
201
+ # Collect custom/unknown sections
202
+ known_sections = {"server", "safety", "browser", "metrics", "logging", "kubernetes"}
203
+ custom = {k: v for k, v in config_dict.items() if k not in known_sections}
204
+
205
+ return Config(
206
+ server=server,
207
+ safety=safety,
208
+ browser=browser,
209
+ metrics=metrics,
210
+ logging=logging_config,
211
+ kubernetes=kubernetes,
212
+ custom=custom,
213
+ )
214
+
215
+
216
+ def load_config(
217
+ config_file: Optional[Union[str, Path]] = None,
218
+ skip_env: bool = False,
219
+ ) -> Config:
220
+ """Load configuration from files and environment.
221
+
222
+ Loading order (later takes precedence):
223
+ 1. Default values
224
+ 2. Main config file (~/.config/kubectl-mcp-server/config.toml)
225
+ 3. Drop-in files (~/.config/kubectl-mcp-server/config.d/*.toml) in sorted order
226
+ 4. Custom config file (if specified)
227
+ 5. Environment variables (unless skip_env=True)
228
+
229
+ Args:
230
+ config_file: Optional path to a specific config file
231
+ skip_env: If True, skip environment variable overrides
232
+
233
+ Returns:
234
+ Loaded Config instance
235
+ """
236
+ global _config
237
+
238
+ config_dict: Dict[str, Any] = {}
239
+ paths = get_config_paths()
240
+
241
+ # 1. Load main config file if it exists
242
+ main_config = paths["main_config"]
243
+ if main_config.exists():
244
+ try:
245
+ loaded = _load_toml_file(main_config)
246
+ config_dict = _deep_merge(config_dict, loaded)
247
+ logger.debug(f"Loaded config from {main_config}")
248
+ except Exception as e:
249
+ logger.warning(f"Failed to load {main_config}: {e}")
250
+
251
+ # 2. Load drop-in configs in sorted order
252
+ drop_in_dir = paths["drop_in_dir"]
253
+ if drop_in_dir.exists() and drop_in_dir.is_dir():
254
+ drop_in_files = sorted(drop_in_dir.glob("*.toml"))
255
+ for drop_in_file in drop_in_files:
256
+ try:
257
+ loaded = _load_toml_file(drop_in_file)
258
+ config_dict = _deep_merge(config_dict, loaded)
259
+ logger.debug(f"Loaded drop-in config from {drop_in_file}")
260
+ except Exception as e:
261
+ logger.warning(f"Failed to load {drop_in_file}: {e}")
262
+
263
+ # 3. Load custom config file if specified
264
+ if config_file:
265
+ config_path = Path(config_file)
266
+ if config_path.exists():
267
+ try:
268
+ loaded = _load_toml_file(config_path)
269
+ config_dict = _deep_merge(config_dict, loaded)
270
+ logger.debug(f"Loaded custom config from {config_path}")
271
+ except Exception as e:
272
+ logger.warning(f"Failed to load {config_path}: {e}")
273
+ else:
274
+ logger.warning(f"Config file not found: {config_path}")
275
+
276
+ # 4. Apply environment variable overrides
277
+ if not skip_env:
278
+ config_dict = _apply_env_overrides(config_dict)
279
+
280
+ # 5. Validate configuration
281
+ errors = validate_config(config_dict)
282
+ if errors:
283
+ for error in errors:
284
+ logger.error(f"Config validation error: {error}")
285
+
286
+ # 6. Convert to Config dataclass
287
+ _config = _dict_to_config(config_dict)
288
+
289
+ return _config
290
+
291
+
292
+ def get_config() -> Config:
293
+ """Get the current configuration, loading if necessary.
294
+
295
+ Returns:
296
+ Current Config instance
297
+ """
298
+ global _config
299
+ if _config is None:
300
+ _config = load_config()
301
+ return _config
302
+
303
+
304
+ def reload_config() -> Config:
305
+ """Reload configuration from files.
306
+
307
+ This is called by the SIGHUP handler for runtime config updates.
308
+
309
+ Returns:
310
+ Newly loaded Config instance
311
+ """
312
+ global _config
313
+
314
+ logger.info("Reloading configuration...")
315
+ old_config = _config
316
+
317
+ try:
318
+ _config = load_config()
319
+ logger.info("Configuration reloaded successfully")
320
+
321
+ # Notify callbacks
322
+ for callback in _config_callbacks:
323
+ try:
324
+ callback(_config)
325
+ except Exception as e:
326
+ logger.error(f"Config reload callback failed: {e}")
327
+
328
+ except Exception as e:
329
+ logger.error(f"Failed to reload configuration: {e}")
330
+ _config = old_config
331
+ raise
332
+
333
+ return _config
334
+
335
+
336
+ def register_reload_callback(callback: Callable[[Config], None]) -> None:
337
+ """Register a callback to be called when configuration is reloaded.
338
+
339
+ Args:
340
+ callback: Function that takes the new Config as argument
341
+ """
342
+ _config_callbacks.append(callback)
343
+
344
+
345
+ def unregister_reload_callback(callback: Callable[[Config], None]) -> None:
346
+ """Unregister a previously registered reload callback.
347
+
348
+ Args:
349
+ callback: The callback function to remove
350
+ """
351
+ try:
352
+ _config_callbacks.remove(callback)
353
+ except ValueError:
354
+ pass
355
+
356
+
357
+ def _sighup_handler(signum: int, frame: Any) -> None:
358
+ """Handle SIGHUP signal for configuration reload."""
359
+ logger.info("Received SIGHUP, reloading configuration...")
360
+ try:
361
+ reload_config()
362
+ except Exception as e:
363
+ logger.error(f"Configuration reload failed: {e}")
364
+
365
+
366
+ def setup_sighup_handler() -> bool:
367
+ """Set up SIGHUP handler for runtime configuration reload.
368
+
369
+ Returns:
370
+ True if handler was set up, False if not supported (e.g., Windows)
371
+ """
372
+ if sys.platform == "win32":
373
+ logger.debug("SIGHUP not supported on Windows")
374
+ return False
375
+
376
+ try:
377
+ signal.signal(signal.SIGHUP, _sighup_handler)
378
+ logger.debug("SIGHUP handler installed for config reload")
379
+ return True
380
+ except (OSError, AttributeError) as e:
381
+ logger.warning(f"Could not install SIGHUP handler: {e}")
382
+ return False
383
+
384
+
385
+ # Re-export Config from schema
386
+ Config = Config
@@ -0,0 +1,184 @@
1
+ """Configuration schema definitions for kubectl-mcp-server.
2
+
3
+ Defines dataclasses for type-safe configuration with validation.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class ServerConfig:
15
+ """MCP server configuration."""
16
+
17
+ transport: str = "streamable-http"
18
+ host: str = "127.0.0.1"
19
+ port: int = 8000
20
+ debug: bool = False
21
+ log_file: Optional[str] = None
22
+ log_level: str = "INFO"
23
+
24
+ def __post_init__(self):
25
+ """Validate configuration values."""
26
+ valid_transports = {"stdio", "sse", "streamable-http"}
27
+ if self.transport not in valid_transports:
28
+ raise ValueError(f"Invalid transport: {self.transport}. Must be one of {valid_transports}")
29
+
30
+ if not 1 <= self.port <= 65535:
31
+ raise ValueError(f"Invalid port: {self.port}. Must be between 1 and 65535")
32
+
33
+ valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
34
+ if self.log_level.upper() not in valid_log_levels:
35
+ raise ValueError(f"Invalid log_level: {self.log_level}. Must be one of {valid_log_levels}")
36
+
37
+
38
+ @dataclass
39
+ class SafetyConfig:
40
+ """Safety mode configuration."""
41
+
42
+ mode: str = "normal"
43
+
44
+ # Additional safety settings
45
+ confirm_destructive: bool = False
46
+ max_delete_count: int = 10
47
+ blocked_namespaces: List[str] = field(default_factory=lambda: ["kube-system", "kube-public"])
48
+
49
+ def __post_init__(self):
50
+ """Validate safety mode."""
51
+ valid_modes = {"normal", "read-only", "disable-destructive"}
52
+ if self.mode not in valid_modes:
53
+ raise ValueError(f"Invalid safety mode: {self.mode}. Must be one of {valid_modes}")
54
+
55
+
56
+ @dataclass
57
+ class BrowserConfig:
58
+ """Browser automation configuration."""
59
+
60
+ enabled: bool = False
61
+ provider: str = "local"
62
+ headed: bool = False
63
+ timeout: int = 60
64
+ max_retries: int = 3
65
+
66
+ # Provider-specific settings
67
+ browserbase_api_key: Optional[str] = None
68
+ browserbase_project_id: Optional[str] = None
69
+ browseruse_api_key: Optional[str] = None
70
+ cdp_url: Optional[str] = None
71
+
72
+ def __post_init__(self):
73
+ """Validate browser configuration."""
74
+ valid_providers = {"local", "browserbase", "browseruse", "cdp"}
75
+ if self.provider not in valid_providers:
76
+ raise ValueError(f"Invalid browser provider: {self.provider}. Must be one of {valid_providers}")
77
+
78
+
79
+ @dataclass
80
+ class MetricsConfig:
81
+ """Metrics and observability configuration."""
82
+
83
+ enabled: bool = False
84
+ endpoint: str = "/metrics"
85
+
86
+ # Tracing settings
87
+ tracing_enabled: bool = False
88
+ otlp_endpoint: Optional[str] = None
89
+ service_name: str = "kubectl-mcp-server"
90
+ sample_rate: float = 1.0
91
+
92
+ def __post_init__(self):
93
+ """Validate metrics configuration."""
94
+ if not 0.0 <= self.sample_rate <= 1.0:
95
+ raise ValueError(f"Invalid sample_rate: {self.sample_rate}. Must be between 0.0 and 1.0")
96
+
97
+
98
+ @dataclass
99
+ class LoggingConfig:
100
+ """Logging configuration."""
101
+
102
+ level: str = "INFO"
103
+ format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
104
+ file: Optional[str] = None
105
+ max_size_mb: int = 10
106
+ backup_count: int = 5
107
+
108
+ def __post_init__(self):
109
+ """Validate logging configuration."""
110
+ valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
111
+ if self.level.upper() not in valid_levels:
112
+ raise ValueError(f"Invalid log level: {self.level}. Must be one of {valid_levels}")
113
+
114
+
115
+ @dataclass
116
+ class KubernetesConfig:
117
+ """Kubernetes client configuration."""
118
+
119
+ context: Optional[str] = None
120
+ kubeconfig: Optional[str] = None
121
+ in_cluster: bool = False
122
+ default_namespace: str = "default"
123
+ timeout: int = 30
124
+
125
+
126
+ @dataclass
127
+ class Config:
128
+ """Root configuration container."""
129
+
130
+ server: ServerConfig = field(default_factory=ServerConfig)
131
+ safety: SafetyConfig = field(default_factory=SafetyConfig)
132
+ browser: BrowserConfig = field(default_factory=BrowserConfig)
133
+ metrics: MetricsConfig = field(default_factory=MetricsConfig)
134
+ logging: LoggingConfig = field(default_factory=LoggingConfig)
135
+ kubernetes: KubernetesConfig = field(default_factory=KubernetesConfig)
136
+
137
+ # Custom settings from drop-in configs
138
+ custom: Dict[str, Any] = field(default_factory=dict)
139
+
140
+
141
+ def validate_config(config_dict: Dict[str, Any]) -> List[str]:
142
+ """Validate configuration dictionary and return list of errors.
143
+
144
+ Args:
145
+ config_dict: Configuration dictionary to validate
146
+
147
+ Returns:
148
+ List of validation error messages (empty if valid)
149
+ """
150
+ errors = []
151
+
152
+ # Validate server section
153
+ if "server" in config_dict:
154
+ server = config_dict["server"]
155
+ valid_transports = {"stdio", "sse", "streamable-http"}
156
+ if server.get("transport") and server["transport"] not in valid_transports:
157
+ errors.append(f"Invalid server.transport: {server['transport']}")
158
+
159
+ port = server.get("port")
160
+ if port is not None and not (1 <= port <= 65535):
161
+ errors.append(f"Invalid server.port: {port}")
162
+
163
+ # Validate safety section
164
+ if "safety" in config_dict:
165
+ safety = config_dict["safety"]
166
+ valid_modes = {"normal", "read-only", "disable-destructive"}
167
+ if safety.get("mode") and safety["mode"] not in valid_modes:
168
+ errors.append(f"Invalid safety.mode: {safety['mode']}")
169
+
170
+ # Validate browser section
171
+ if "browser" in config_dict:
172
+ browser = config_dict["browser"]
173
+ valid_providers = {"local", "browserbase", "browseruse", "cdp"}
174
+ if browser.get("provider") and browser["provider"] not in valid_providers:
175
+ errors.append(f"Invalid browser.provider: {browser['provider']}")
176
+
177
+ # Validate metrics section
178
+ if "metrics" in config_dict:
179
+ metrics = config_dict["metrics"]
180
+ sample_rate = metrics.get("sample_rate")
181
+ if sample_rate is not None and not (0.0 <= sample_rate <= 1.0):
182
+ errors.append(f"Invalid metrics.sample_rate: {sample_rate}")
183
+
184
+ return errors