kubectl-mcp-server 1.16.0__tar.gz → 1.17.0__tar.gz

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 (82) hide show
  1. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/PKG-INFO +1 -1
  2. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/PKG-INFO +1 -1
  3. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/SOURCES.txt +14 -0
  4. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/__init__.py +1 -1
  5. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/cli/cli.py +83 -9
  6. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/cli/output.py +14 -0
  7. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/config/__init__.py +46 -0
  8. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/config/loader.py +386 -0
  9. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/config/schema.py +184 -0
  10. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/mcp_server.py +219 -8
  11. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/observability/__init__.py +59 -0
  12. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/observability/metrics.py +223 -0
  13. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/observability/stats.py +255 -0
  14. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/observability/tracing.py +335 -0
  15. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/prompts/__init__.py +48 -0
  16. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/prompts/builtin.py +695 -0
  17. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/prompts/custom.py +298 -0
  18. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/prompts/prompts.py +180 -4
  19. kubectl_mcp_server-1.17.0/kubectl_mcp_tool/safety.py +155 -0
  20. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/cluster.py +384 -0
  21. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/setup.py +1 -1
  22. kubectl_mcp_server-1.17.0/tests/test_config.py +386 -0
  23. kubectl_mcp_server-1.17.0/tests/test_mcp_integration.py +251 -0
  24. kubectl_mcp_server-1.17.0/tests/test_observability.py +521 -0
  25. kubectl_mcp_server-1.17.0/tests/test_prompts.py +1252 -0
  26. kubectl_mcp_server-1.17.0/tests/test_safety.py +218 -0
  27. kubectl_mcp_server-1.16.0/kubectl_mcp_tool/prompts/__init__.py +0 -5
  28. kubectl_mcp_server-1.16.0/tests/test_prompts.py +0 -536
  29. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/LICENSE +0 -0
  30. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/README.md +0 -0
  31. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/dependency_links.txt +0 -0
  32. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/entry_points.txt +0 -0
  33. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/requires.txt +0 -0
  34. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_server.egg-info/top_level.txt +0 -0
  35. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/__main__.py +0 -0
  36. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/auth/__init__.py +0 -0
  37. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/auth/config.py +0 -0
  38. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/auth/scopes.py +0 -0
  39. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/auth/verifier.py +0 -0
  40. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/cli/__init__.py +0 -0
  41. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/cli/__main__.py +0 -0
  42. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/cli/errors.py +0 -0
  43. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/crd_detector.py +0 -0
  44. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/diagnostics.py +0 -0
  45. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/k8s_config.py +0 -0
  46. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/resources/__init__.py +0 -0
  47. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/resources/resources.py +0 -0
  48. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/__init__.py +0 -0
  49. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/backup.py +0 -0
  50. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/browser.py +0 -0
  51. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/capi.py +0 -0
  52. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/certs.py +0 -0
  53. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/cilium.py +0 -0
  54. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/core.py +0 -0
  55. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/cost.py +0 -0
  56. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/deployments.py +0 -0
  57. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/diagnostics.py +0 -0
  58. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/gitops.py +0 -0
  59. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/helm.py +0 -0
  60. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/keda.py +0 -0
  61. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/kiali.py +0 -0
  62. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/kubevirt.py +0 -0
  63. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/networking.py +0 -0
  64. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/operations.py +0 -0
  65. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/pods.py +0 -0
  66. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/policy.py +0 -0
  67. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/rollouts.py +0 -0
  68. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/security.py +0 -0
  69. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/storage.py +0 -0
  70. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/tools/ui.py +0 -0
  71. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/utils/__init__.py +0 -0
  72. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/kubectl_mcp_tool/utils/helpers.py +0 -0
  73. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/setup.cfg +0 -0
  74. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/__init__.py +0 -0
  75. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/conftest.py +0 -0
  76. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_auth.py +0 -0
  77. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_browser.py +0 -0
  78. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_cli.py +0 -0
  79. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_ecosystem.py +0 -0
  80. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_resources.py +0 -0
  81. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_server.py +0 -0
  82. {kubectl_mcp_server-1.16.0 → kubectl_mcp_server-1.17.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kubectl-mcp-server
3
- Version: 1.16.0
3
+ Version: 1.17.0
4
4
  Summary: A Model Context Protocol (MCP) server for Kubernetes with 220+ tools, 8 resources, and 8 prompts
5
5
  Home-page: https://github.com/rohitg00/kubectl-mcp-server
6
6
  Author: Rohit Ghumare
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kubectl-mcp-server
3
- Version: 1.16.0
3
+ Version: 1.17.0
4
4
  Summary: A Model Context Protocol (MCP) server for Kubernetes with 220+ tools, 8 resources, and 8 prompts
5
5
  Home-page: https://github.com/rohitg00/kubectl-mcp-server
6
6
  Author: Rohit Ghumare
@@ -13,6 +13,7 @@ kubectl_mcp_tool/crd_detector.py
13
13
  kubectl_mcp_tool/diagnostics.py
14
14
  kubectl_mcp_tool/k8s_config.py
15
15
  kubectl_mcp_tool/mcp_server.py
16
+ kubectl_mcp_tool/safety.py
16
17
  kubectl_mcp_tool/auth/__init__.py
17
18
  kubectl_mcp_tool/auth/config.py
18
19
  kubectl_mcp_tool/auth/scopes.py
@@ -22,7 +23,16 @@ kubectl_mcp_tool/cli/__main__.py
22
23
  kubectl_mcp_tool/cli/cli.py
23
24
  kubectl_mcp_tool/cli/errors.py
24
25
  kubectl_mcp_tool/cli/output.py
26
+ kubectl_mcp_tool/config/__init__.py
27
+ kubectl_mcp_tool/config/loader.py
28
+ kubectl_mcp_tool/config/schema.py
29
+ kubectl_mcp_tool/observability/__init__.py
30
+ kubectl_mcp_tool/observability/metrics.py
31
+ kubectl_mcp_tool/observability/stats.py
32
+ kubectl_mcp_tool/observability/tracing.py
25
33
  kubectl_mcp_tool/prompts/__init__.py
34
+ kubectl_mcp_tool/prompts/builtin.py
35
+ kubectl_mcp_tool/prompts/custom.py
26
36
  kubectl_mcp_tool/prompts/prompts.py
27
37
  kubectl_mcp_tool/resources/__init__.py
28
38
  kubectl_mcp_tool/resources/resources.py
@@ -57,8 +67,12 @@ tests/conftest.py
57
67
  tests/test_auth.py
58
68
  tests/test_browser.py
59
69
  tests/test_cli.py
70
+ tests/test_config.py
60
71
  tests/test_ecosystem.py
72
+ tests/test_mcp_integration.py
73
+ tests/test_observability.py
61
74
  tests/test_prompts.py
62
75
  tests/test_resources.py
76
+ tests/test_safety.py
63
77
  tests/test_server.py
64
78
  tests/test_tools.py
@@ -7,7 +7,7 @@ with Kubernetes clusters through natural language commands.
7
7
  For more information, see: https://github.com/rohitg00/kubectl-mcp-server
8
8
  """
9
9
 
10
- __version__ = "1.16.0"
10
+ __version__ = "1.17.0"
11
11
 
12
12
  from .mcp_server import MCPServer
13
13
  from .diagnostics import run_diagnostics, check_kubectl_installation, check_cluster_connection
@@ -37,6 +37,7 @@ from .output import (
37
37
  format_error,
38
38
  format_success,
39
39
  )
40
+ from ..safety import SafetyMode, set_safety_mode, get_mode_info
40
41
 
41
42
  # Logging setup
42
43
  log_file = os.environ.get("MCP_LOG_FILE")
@@ -56,18 +57,49 @@ logging.basicConfig(
56
57
  logger = logging.getLogger("kubectl-mcp-cli")
57
58
 
58
59
 
59
- async def serve_stdio():
60
- server = MCPServer("kubernetes")
60
+ async def serve_stdio(
61
+ read_only: bool = False,
62
+ disable_destructive: bool = False,
63
+ config_file: Optional[str] = None,
64
+ ):
65
+ server = MCPServer(
66
+ "kubernetes",
67
+ read_only=read_only,
68
+ disable_destructive=disable_destructive,
69
+ config_file=config_file,
70
+ )
61
71
  await server.serve_stdio()
62
72
 
63
73
 
64
- async def serve_sse(host: str, port: int):
65
- server = MCPServer("kubernetes")
74
+ async def serve_sse(
75
+ host: str,
76
+ port: int,
77
+ read_only: bool = False,
78
+ disable_destructive: bool = False,
79
+ config_file: Optional[str] = None,
80
+ ):
81
+ server = MCPServer(
82
+ "kubernetes",
83
+ read_only=read_only,
84
+ disable_destructive=disable_destructive,
85
+ config_file=config_file,
86
+ )
66
87
  await server.serve_sse(host=host, port=port)
67
88
 
68
89
 
69
- async def serve_http(host: str, port: int):
70
- server = MCPServer("kubernetes")
90
+ async def serve_http(
91
+ host: str,
92
+ port: int,
93
+ read_only: bool = False,
94
+ disable_destructive: bool = False,
95
+ config_file: Optional[str] = None,
96
+ ):
97
+ server = MCPServer(
98
+ "kubernetes",
99
+ read_only=read_only,
100
+ disable_destructive=disable_destructive,
101
+ config_file=config_file,
102
+ )
71
103
  await server.serve_http(host=host, port=port)
72
104
 
73
105
 
@@ -282,12 +314,16 @@ def cmd_info(args):
282
314
  except Exception:
283
315
  pass
284
316
 
317
+ # Get safety mode info
318
+ mode_info = get_mode_info()
319
+
285
320
  print(format_server_info(
286
321
  version=__version__,
287
322
  tool_count=len(tools),
288
323
  resource_count=len(resources),
289
324
  prompt_count=len(prompts),
290
325
  context=context,
326
+ safety_mode=mode_info,
291
327
  as_json=getattr(args, 'json', False)
292
328
  ))
293
329
  return ErrorCode.SUCCESS
@@ -407,6 +443,16 @@ def cmd_doctor(args):
407
443
  except ImportError:
408
444
  checks.append({"name": "fastmcp", "status": "error", "details": "fastmcp not installed"})
409
445
 
446
+ # Check safety mode
447
+ mode_info = get_mode_info()
448
+ mode = mode_info.get("mode", "normal")
449
+ if mode == "normal":
450
+ checks.append({"name": "safety_mode", "status": "ok", "version": mode, "details": "All operations allowed"})
451
+ elif mode == "read_only":
452
+ checks.append({"name": "safety_mode", "status": "warning", "version": mode, "details": "Write operations blocked"})
453
+ else:
454
+ checks.append({"name": "safety_mode", "status": "warning", "version": mode, "details": "Destructive operations blocked"})
455
+
410
456
  print(format_doctor_results(checks, as_json=getattr(args, 'json', False)))
411
457
 
412
458
  # Return error code if any critical checks failed
@@ -453,6 +499,9 @@ Environment Variables:
453
499
  )
454
500
  serve_parser.add_argument("--host", type=str, default="0.0.0.0", help="Host for SSE/HTTP (default: 0.0.0.0)")
455
501
  serve_parser.add_argument("--port", type=int, default=8000, help="Port for SSE/HTTP (default: 8000)")
502
+ serve_parser.add_argument("--config", type=str, default=None, help="Path to TOML configuration file")
503
+ serve_parser.add_argument("--read-only", action="store_true", help="Enable read-only mode (block all write operations)")
504
+ serve_parser.add_argument("--disable-destructive", action="store_true", help="Disable destructive operations (allow create/update, block delete)")
456
505
 
457
506
  # version command (existing)
458
507
  subparsers.add_parser("version", help="Show version")
@@ -507,12 +556,37 @@ Environment Variables:
507
556
 
508
557
  try:
509
558
  if args.command == "serve":
559
+ # Log safety mode (actual mode is applied in MCPServer)
560
+ if args.read_only:
561
+ logger.info("Starting server in READ-ONLY mode")
562
+ elif args.disable_destructive:
563
+ logger.info("Starting server with destructive operations disabled")
564
+
565
+ if args.config:
566
+ logger.info(f"Loading configuration from: {args.config}")
567
+
510
568
  if args.transport == "stdio":
511
- asyncio.run(serve_stdio())
569
+ asyncio.run(serve_stdio(
570
+ read_only=args.read_only,
571
+ disable_destructive=args.disable_destructive,
572
+ config_file=args.config,
573
+ ))
512
574
  elif args.transport == "sse":
513
- asyncio.run(serve_sse(args.host, args.port))
575
+ asyncio.run(serve_sse(
576
+ host=args.host,
577
+ port=args.port,
578
+ read_only=args.read_only,
579
+ disable_destructive=args.disable_destructive,
580
+ config_file=args.config,
581
+ ))
514
582
  elif args.transport in ("http", "streamable-http"):
515
- asyncio.run(serve_http(args.host, args.port))
583
+ asyncio.run(serve_http(
584
+ host=args.host,
585
+ port=args.port,
586
+ read_only=args.read_only,
587
+ disable_destructive=args.disable_destructive,
588
+ config_file=args.config,
589
+ ))
516
590
 
517
591
  elif args.command == "version":
518
592
  from .. import __version__
@@ -266,6 +266,7 @@ def format_server_info(
266
266
  resource_count: int,
267
267
  prompt_count: int,
268
268
  context: Optional[str] = None,
269
+ safety_mode: Optional[Dict[str, Any]] = None,
269
270
  as_json: bool = False
270
271
  ) -> str:
271
272
  info = {
@@ -276,6 +277,9 @@ def format_server_info(
276
277
  "k8s_context": context,
277
278
  }
278
279
 
280
+ if safety_mode:
281
+ info["safety_mode"] = safety_mode
282
+
279
283
  if as_json:
280
284
  return json.dumps(info, indent=2)
281
285
 
@@ -291,6 +295,16 @@ def format_server_info(
291
295
  if context:
292
296
  lines.append(f" {cyan('K8s Context:')} {context}")
293
297
 
298
+ if safety_mode:
299
+ mode = safety_mode.get("mode", "normal")
300
+ if mode == "normal":
301
+ mode_str = green(mode)
302
+ elif mode == "read_only":
303
+ mode_str = yellow(mode) + " (write operations blocked)"
304
+ else:
305
+ mode_str = yellow(mode) + " (destructive operations blocked)"
306
+ lines.append(f" {cyan('Safety Mode:')} {mode_str}")
307
+
294
308
  return "\n".join(lines)
295
309
 
296
310
 
@@ -0,0 +1,46 @@
1
+ """Configuration management for kubectl-mcp-server.
2
+
3
+ This module provides TOML-based configuration with:
4
+ - Main config file support
5
+ - Drop-in directory for modular configuration
6
+ - SIGHUP reload for runtime updates
7
+ - Environment variable overrides
8
+ """
9
+
10
+ from .loader import (
11
+ Config,
12
+ load_config,
13
+ get_config,
14
+ reload_config,
15
+ get_config_paths,
16
+ setup_sighup_handler,
17
+ register_reload_callback,
18
+ unregister_reload_callback,
19
+ )
20
+ from .schema import (
21
+ ServerConfig,
22
+ SafetyConfig,
23
+ BrowserConfig,
24
+ MetricsConfig,
25
+ LoggingConfig,
26
+ validate_config,
27
+ )
28
+
29
+ __all__ = [
30
+ # Config loading
31
+ "Config",
32
+ "load_config",
33
+ "get_config",
34
+ "reload_config",
35
+ "get_config_paths",
36
+ "setup_sighup_handler",
37
+ "register_reload_callback",
38
+ "unregister_reload_callback",
39
+ # Config schemas
40
+ "ServerConfig",
41
+ "SafetyConfig",
42
+ "BrowserConfig",
43
+ "MetricsConfig",
44
+ "LoggingConfig",
45
+ "validate_config",
46
+ ]
@@ -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