kubectl-mcp-server 1.16.0__py3-none-any.whl → 1.18.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 (30) hide show
  1. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/METADATA +48 -1
  2. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/RECORD +30 -15
  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/k8s_config.py +127 -1
  10. kubectl_mcp_tool/mcp_server.py +219 -8
  11. kubectl_mcp_tool/observability/__init__.py +59 -0
  12. kubectl_mcp_tool/observability/metrics.py +223 -0
  13. kubectl_mcp_tool/observability/stats.py +255 -0
  14. kubectl_mcp_tool/observability/tracing.py +335 -0
  15. kubectl_mcp_tool/prompts/__init__.py +43 -0
  16. kubectl_mcp_tool/prompts/builtin.py +695 -0
  17. kubectl_mcp_tool/prompts/custom.py +298 -0
  18. kubectl_mcp_tool/prompts/prompts.py +180 -4
  19. kubectl_mcp_tool/providers.py +347 -0
  20. kubectl_mcp_tool/safety.py +155 -0
  21. kubectl_mcp_tool/tools/cluster.py +384 -0
  22. tests/test_config.py +386 -0
  23. tests/test_mcp_integration.py +251 -0
  24. tests/test_observability.py +521 -0
  25. tests/test_prompts.py +716 -0
  26. tests/test_safety.py +218 -0
  27. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/WHEEL +0 -0
  28. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/entry_points.txt +0 -0
  29. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/licenses/LICENSE +0 -0
  30. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/top_level.txt +0 -0
@@ -6,14 +6,39 @@ Supports multi-cluster operations with context targeting.
6
6
 
7
7
  This module provides context-aware client creation for multi-cluster support.
8
8
  All get_*_client() functions accept an optional 'context' parameter.
9
+
10
+ Environment Variables:
11
+ MCP_K8S_PROVIDER: Provider type (kubeconfig, in-cluster, single)
12
+ MCP_K8S_KUBECONFIG: Path to kubeconfig file
13
+ MCP_K8S_CONTEXT: Default context for single provider
14
+ MCP_K8S_QPS: API rate limit (default: 100)
15
+ MCP_K8S_BURST: API burst limit (default: 200)
16
+ MCP_K8S_TIMEOUT: Request timeout in seconds (default: 30)
9
17
  """
10
18
 
11
19
  import os
12
20
  import logging
13
- from typing import Optional, Any
21
+ from typing import Optional, Any, List, Dict
14
22
 
15
23
  logger = logging.getLogger("mcp-server")
16
24
 
25
+ # Try to import provider module for enhanced features
26
+ try:
27
+ from .providers import (
28
+ KubernetesProvider,
29
+ ProviderConfig,
30
+ ProviderType,
31
+ UnknownContextError,
32
+ get_provider,
33
+ get_context_names,
34
+ get_current_context as provider_get_current_context,
35
+ validate_context,
36
+ )
37
+ _HAS_PROVIDER = True
38
+ except ImportError:
39
+ _HAS_PROVIDER = False
40
+ logger.debug("Provider module not available, using basic config")
41
+
17
42
  _config_loaded = False
18
43
  _original_load_kube_config = None
19
44
 
@@ -114,12 +139,29 @@ def _load_config_for_context(context: str = "") -> Any:
114
139
  """
115
140
  Load kubernetes config for a specific context and return ApiClient.
116
141
 
142
+ Uses the provider module for caching when available.
143
+
117
144
  Args:
118
145
  context: Context name (empty for default)
119
146
 
120
147
  Returns:
121
148
  kubernetes.client.ApiClient configured for the context
149
+
150
+ Raises:
151
+ UnknownContextError: If context is not found (when provider available)
152
+ RuntimeError: If config cannot be loaded
122
153
  """
154
+ # Use provider module if available (provides caching and validation)
155
+ if _HAS_PROVIDER:
156
+ try:
157
+ provider = get_provider()
158
+ return provider.get_api_client(context)
159
+ except UnknownContextError:
160
+ raise
161
+ except Exception as e:
162
+ logger.warning(f"Provider failed, falling back to basic config: {e}")
163
+
164
+ # Fallback to basic config loading
123
165
  from kubernetes import client, config
124
166
  from kubernetes.config.config_exception import ConfigException
125
167
 
@@ -454,6 +496,25 @@ def list_contexts() -> list:
454
496
  Returns:
455
497
  List of context dictionaries with name, cluster, user, namespace
456
498
  """
499
+ # Use provider if available
500
+ if _HAS_PROVIDER:
501
+ try:
502
+ provider = get_provider()
503
+ contexts = provider.list_contexts()
504
+ return [
505
+ {
506
+ "name": ctx.name,
507
+ "cluster": ctx.cluster,
508
+ "user": ctx.user,
509
+ "namespace": ctx.namespace,
510
+ "active": ctx.is_active
511
+ }
512
+ for ctx in contexts
513
+ ]
514
+ except Exception as e:
515
+ logger.warning(f"Provider list_contexts failed: {e}")
516
+
517
+ # Fallback to direct kubeconfig reading
457
518
  from kubernetes import config
458
519
 
459
520
  try:
@@ -484,6 +545,14 @@ def get_active_context() -> Optional[str]:
484
545
  Returns:
485
546
  Active context name or None
486
547
  """
548
+ # Use provider if available
549
+ if _HAS_PROVIDER:
550
+ try:
551
+ return provider_get_current_context()
552
+ except Exception as e:
553
+ logger.warning(f"Provider get_current_context failed: {e}")
554
+
555
+ # Fallback to direct kubeconfig reading
487
556
  from kubernetes import config
488
557
 
489
558
  try:
@@ -528,3 +597,60 @@ def _get_kubectl_context_args(context: str = "") -> list:
528
597
  if context and context.strip():
529
598
  return ["--context", context.strip()]
530
599
  return []
600
+
601
+
602
+ # Re-export provider types for convenience
603
+ if _HAS_PROVIDER:
604
+ __all__ = [
605
+ # Client functions
606
+ "get_k8s_client",
607
+ "get_apps_client",
608
+ "get_rbac_client",
609
+ "get_networking_client",
610
+ "get_storage_client",
611
+ "get_batch_client",
612
+ "get_autoscaling_client",
613
+ "get_policy_client",
614
+ "get_custom_objects_client",
615
+ "get_version_client",
616
+ "get_admissionregistration_client",
617
+ "get_apiextensions_client",
618
+ "get_coordination_client",
619
+ "get_events_client",
620
+ # Config functions
621
+ "load_kubernetes_config",
622
+ "patch_kubernetes_config",
623
+ # Context functions
624
+ "list_contexts",
625
+ "get_active_context",
626
+ "context_exists",
627
+ # Provider types (when available)
628
+ "KubernetesProvider",
629
+ "ProviderConfig",
630
+ "ProviderType",
631
+ "UnknownContextError",
632
+ "get_provider",
633
+ "validate_context",
634
+ ]
635
+ else:
636
+ __all__ = [
637
+ "get_k8s_client",
638
+ "get_apps_client",
639
+ "get_rbac_client",
640
+ "get_networking_client",
641
+ "get_storage_client",
642
+ "get_batch_client",
643
+ "get_autoscaling_client",
644
+ "get_policy_client",
645
+ "get_custom_objects_client",
646
+ "get_version_client",
647
+ "get_admissionregistration_client",
648
+ "get_apiextensions_client",
649
+ "get_coordination_client",
650
+ "get_events_client",
651
+ "load_kubernetes_config",
652
+ "patch_kubernetes_config",
653
+ "list_contexts",
654
+ "get_active_context",
655
+ "context_exists",
656
+ ]
@@ -23,12 +23,49 @@ import logging
23
23
  import asyncio
24
24
  import os
25
25
  import platform
26
- from typing import List, Optional, Any
26
+ import signal
27
+ from pathlib import Path
28
+ from typing import List, Optional, Any, Dict
27
29
 
28
30
  # Import k8s_config early to patch kubernetes config for in-cluster support
29
31
  # This must be done before any tools are imported
30
32
  import kubectl_mcp_tool.k8s_config # noqa: F401
31
33
 
34
+ # Import safety mode for operation control
35
+ from kubectl_mcp_tool.safety import (
36
+ SafetyMode,
37
+ set_safety_mode,
38
+ get_safety_mode,
39
+ get_mode_info,
40
+ )
41
+
42
+ # Import observability for metrics and tracing
43
+ from kubectl_mcp_tool.observability import (
44
+ get_stats_collector,
45
+ get_metrics,
46
+ init_tracing,
47
+ shutdown_tracing,
48
+ is_prometheus_available,
49
+ is_tracing_available,
50
+ record_tool_call_metric,
51
+ record_tool_duration_metric,
52
+ record_tool_error_metric,
53
+ )
54
+
55
+ # Import config loader
56
+ from kubectl_mcp_tool.config import (
57
+ load_config,
58
+ get_config,
59
+ register_reload_callback,
60
+ setup_sighup_handler,
61
+ )
62
+
63
+ # Import custom prompts
64
+ from kubectl_mcp_tool.prompts import (
65
+ load_prompts_from_config,
66
+ get_builtin_prompts,
67
+ )
68
+
32
69
  from kubectl_mcp_tool.tools import (
33
70
  register_helm_tools,
34
71
  register_pod_tools,
@@ -116,12 +153,20 @@ except ImportError:
116
153
  class MCPServer:
117
154
  """MCP server implementation."""
118
155
 
119
- def __init__(self, name: str, non_destructive: bool = False):
156
+ def __init__(
157
+ self,
158
+ name: str,
159
+ read_only: bool = False,
160
+ disable_destructive: bool = False,
161
+ config_file: Optional[str] = None,
162
+ ):
120
163
  """Initialize the MCP server.
121
164
 
122
165
  Args:
123
166
  name: Server name for identification
124
- non_destructive: If True, block destructive operations
167
+ read_only: If True, block all write operations (read-only mode)
168
+ disable_destructive: If True, block only destructive operations
169
+ config_file: Optional path to TOML config file
125
170
 
126
171
  Environment Variables:
127
172
  MCP_AUTH_ENABLED: Enable OAuth 2.1 authentication (default: false)
@@ -131,9 +176,29 @@ class MCPServer:
131
176
  MCP_AUTH_REQUIRED_SCOPES: Required scopes (default: mcp:tools)
132
177
  """
133
178
  self.name = name
134
- self.non_destructive = non_destructive
135
179
  self._dependencies_checked = False
136
180
  self._dependencies_available = None
181
+ self._stats = get_stats_collector()
182
+
183
+ # Persist CLI safety overrides for reloads
184
+ self._cli_read_only = read_only
185
+ self._cli_disable_destructive = disable_destructive
186
+
187
+ # Load configuration from file and environment
188
+ self.config = self._load_configuration(config_file)
189
+
190
+ # Apply safety mode from config or parameters
191
+ self._apply_safety_mode(self._cli_read_only, self._cli_disable_destructive)
192
+
193
+ # For backward compatibility, expose non_destructive
194
+ self.non_destructive = get_safety_mode() != SafetyMode.NORMAL
195
+
196
+ # Initialize observability (tracing, metrics)
197
+ self._init_observability()
198
+
199
+ # Register config reload callback and set up SIGHUP handler
200
+ register_reload_callback(self._on_config_reload)
201
+ setup_sighup_handler()
137
202
 
138
203
  # Load authentication configuration
139
204
  self.auth_config = get_auth_config()
@@ -150,6 +215,71 @@ class MCPServer:
150
215
  self.setup_resources()
151
216
  self.setup_prompts()
152
217
 
218
+ # Log startup info
219
+ mode_info = get_mode_info()
220
+ logger.info(f"MCP Server initialized: {name}")
221
+ logger.info(f"Safety mode: {mode_info['mode']} - {mode_info['description']}")
222
+
223
+ def _load_configuration(self, config_file: Optional[str]) -> Any:
224
+ """Load configuration from TOML file and environment."""
225
+ try:
226
+ config = load_config(config_file=config_file if config_file else None)
227
+ logger.debug(f"Configuration loaded successfully")
228
+ return config
229
+ except Exception as e:
230
+ logger.warning(f"Failed to load config file: {e}. Using defaults.")
231
+ return load_config(skip_env=False)
232
+
233
+ def _apply_safety_mode(self, read_only: bool, disable_destructive: bool) -> None:
234
+ """Apply safety mode from config or CLI parameters.
235
+
236
+ CLI parameters take precedence over config file settings.
237
+ """
238
+ # Check config first
239
+ config_mode = getattr(self.config.safety, 'mode', 'normal') if hasattr(self.config, 'safety') else 'normal'
240
+
241
+ # CLI parameters override config
242
+ if read_only:
243
+ set_safety_mode(SafetyMode.READ_ONLY)
244
+ elif disable_destructive:
245
+ set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
246
+ elif config_mode == 'read-only' or config_mode == 'read_only':
247
+ set_safety_mode(SafetyMode.READ_ONLY)
248
+ elif config_mode == 'disable-destructive' or config_mode == 'disable_destructive':
249
+ set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
250
+ else:
251
+ set_safety_mode(SafetyMode.NORMAL)
252
+
253
+ def _init_observability(self) -> None:
254
+ """Initialize observability components (tracing, metrics)."""
255
+ # Check if tracing is enabled in config
256
+ tracing_enabled = getattr(self.config.metrics, 'tracing_enabled', False) if hasattr(self.config, 'metrics') else False
257
+ otlp_endpoint = getattr(self.config.metrics, 'otlp_endpoint', None) if hasattr(self.config, 'metrics') else None
258
+
259
+ if tracing_enabled or otlp_endpoint or os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT'):
260
+ try:
261
+ init_tracing()
262
+ logger.info("OpenTelemetry tracing initialized")
263
+ except Exception as e:
264
+ logger.warning(f"Failed to initialize tracing: {e}")
265
+
266
+ if is_prometheus_available():
267
+ logger.debug("Prometheus metrics available")
268
+
269
+ def _on_config_reload(self, new_config: Any) -> None:
270
+ """Handle configuration reload (called on SIGHUP)."""
271
+ logger.info("Configuration reloaded")
272
+ self.config = new_config
273
+
274
+ # Re-apply safety mode from new config, honoring CLI precedence
275
+ self._apply_safety_mode(self._cli_read_only, self._cli_disable_destructive)
276
+
277
+ # Refresh non_destructive flag
278
+ self.non_destructive = get_safety_mode() != SafetyMode.NORMAL
279
+
280
+ mode_info = get_mode_info()
281
+ logger.info(f"Safety mode after reload: {mode_info['mode']}")
282
+
153
283
  def _setup_auth(self) -> Optional[Any]:
154
284
  """Set up authentication if enabled."""
155
285
  if not self.auth_config.enabled:
@@ -228,8 +358,12 @@ class MCPServer:
228
358
  register_resources(self.server)
229
359
 
230
360
  def setup_prompts(self):
231
- """Set up MCP prompts."""
232
- register_prompts(self.server)
361
+ """Set up MCP prompts from built-in and custom config."""
362
+ # Get custom prompts path from config if specified
363
+ prompts_config_path = None
364
+ if hasattr(self.config, 'prompts') and hasattr(self.config.prompts, 'file'):
365
+ prompts_config_path = self.config.prompts.file
366
+ register_prompts(self.server, config_path=prompts_config_path)
233
367
 
234
368
  def _check_dependencies(self) -> bool:
235
369
  """Check if required dependencies are available."""
@@ -358,17 +492,51 @@ class MCPServer:
358
492
  try:
359
493
  # FastMCP 3 uses create_sse_app() to create a Starlette ASGI app
360
494
  from fastmcp.server.http import create_sse_app
495
+ from starlette.applications import Starlette
496
+ from starlette.responses import JSONResponse, PlainTextResponse
497
+ from starlette.routing import Route, Mount
498
+
499
+ # Create observability endpoints
500
+ async def health_check(request):
501
+ return JSONResponse({"status": "healthy", "server": self.name})
502
+
503
+ async def stats_endpoint(request):
504
+ stats = self._stats.get_stats()
505
+ return JSONResponse(stats)
506
+
507
+ async def metrics_endpoint(request):
508
+ if is_prometheus_available():
509
+ metrics_text = get_metrics()
510
+ return PlainTextResponse(metrics_text, media_type="text/plain; version=0.0.4; charset=utf-8")
511
+ else:
512
+ return PlainTextResponse("# Prometheus metrics not available\n", media_type="text/plain")
513
+
514
+ async def safety_mode_endpoint(request):
515
+ mode_info = get_mode_info()
516
+ return JSONResponse(mode_info)
361
517
 
362
518
  # Create the SSE Starlette application
363
519
  # message_path: POST endpoint for client messages
364
520
  # sse_path: GET endpoint for SSE event stream
365
- app = create_sse_app(
521
+ sse_app = create_sse_app(
366
522
  self.server,
367
523
  message_path="/messages/",
368
524
  sse_path="/sse"
369
525
  )
370
526
 
527
+ # Create combined app with SSE and observability endpoints
528
+ app = Starlette(
529
+ routes=[
530
+ Route("/health", health_check, methods=["GET"]),
531
+ Route("/stats", stats_endpoint, methods=["GET"]),
532
+ Route("/metrics", metrics_endpoint, methods=["GET"]),
533
+ Route("/safety", safety_mode_endpoint, methods=["GET"]),
534
+ Mount("/", app=sse_app), # Mount SSE app at root
535
+ ]
536
+ )
537
+
371
538
  logger.info(f"SSE endpoints: GET /sse (events), POST /messages/ (messages)")
539
+ logger.info(f"Observability endpoints: GET /health, /stats, /metrics, /safety")
372
540
 
373
541
  # Run with uvicorn
374
542
  config = uvicorn.Config(app, host=host, port=port, log_level="info")
@@ -498,11 +666,33 @@ class MCPServer:
498
666
  """Health check endpoint."""
499
667
  return JSONResponse({"status": "healthy", "server": self.name})
500
668
 
669
+ async def stats_endpoint(request):
670
+ """Return runtime statistics."""
671
+ stats = self._stats.get_stats()
672
+ return JSONResponse(stats)
673
+
674
+ async def metrics_endpoint(request):
675
+ """Return Prometheus-format metrics."""
676
+ from starlette.responses import PlainTextResponse
677
+ if is_prometheus_available():
678
+ metrics_text = get_metrics()
679
+ return PlainTextResponse(metrics_text, media_type="text/plain; version=0.0.4; charset=utf-8")
680
+ else:
681
+ return PlainTextResponse("# Prometheus metrics not available\n", media_type="text/plain")
682
+
683
+ async def safety_mode_endpoint(request):
684
+ """Return current safety mode information."""
685
+ mode_info = get_mode_info()
686
+ return JSONResponse(mode_info)
687
+
501
688
  app = Starlette(
502
689
  routes=[
503
690
  Route("/", handle_mcp_request, methods=["POST"]),
504
691
  Route("/mcp", handle_mcp_request, methods=["POST"]),
505
692
  Route("/health", health_check, methods=["GET"]),
693
+ Route("/stats", stats_endpoint, methods=["GET"]),
694
+ Route("/metrics", metrics_endpoint, methods=["GET"]),
695
+ Route("/safety", safety_mode_endpoint, methods=["GET"]),
506
696
  ]
507
697
  )
508
698
 
@@ -535,10 +725,31 @@ if __name__ == "__main__":
535
725
  default="0.0.0.0",
536
726
  help="Host to bind to for SSE/HTTP transport. Default: 0.0.0.0.",
537
727
  )
728
+ parser.add_argument(
729
+ "--config",
730
+ type=str,
731
+ default=None,
732
+ help="Path to TOML configuration file.",
733
+ )
734
+ parser.add_argument(
735
+ "--read-only",
736
+ action="store_true",
737
+ help="Enable read-only mode (block all write operations).",
738
+ )
739
+ parser.add_argument(
740
+ "--disable-destructive",
741
+ action="store_true",
742
+ help="Disable destructive operations (allow create/update, block delete).",
743
+ )
538
744
  args = parser.parse_args()
539
745
 
540
746
  server_name = "kubectl_mcp_server"
541
- mcp_server = MCPServer(name=server_name)
747
+ mcp_server = MCPServer(
748
+ name=server_name,
749
+ read_only=args.read_only,
750
+ disable_destructive=args.disable_destructive,
751
+ config_file=args.config,
752
+ )
542
753
 
543
754
  # Handle signals gracefully with immediate exit
544
755
  def signal_handler(sig, frame):
@@ -0,0 +1,59 @@
1
+ """
2
+ Observability module for kubectl-mcp-server.
3
+
4
+ Provides:
5
+ - StatsCollector: Runtime statistics and metrics collection
6
+ - Prometheus metrics: Standard Prometheus format metrics
7
+ - OpenTelemetry tracing: Distributed tracing with OTLP export
8
+
9
+ Usage:
10
+ # Stats collection
11
+ from kubectl_mcp_tool.observability import get_stats_collector
12
+ stats = get_stats_collector()
13
+ stats.record_tool_call("get_pods", success=True, duration=0.5)
14
+
15
+ # Prometheus metrics
16
+ from kubectl_mcp_tool.observability import get_metrics
17
+ metrics_text = get_metrics()
18
+
19
+ # Tracing
20
+ from kubectl_mcp_tool.observability import init_tracing, traced_tool_call
21
+ init_tracing()
22
+ with traced_tool_call("get_pods") as span:
23
+ # execute tool
24
+ pass
25
+ """
26
+
27
+ from .stats import StatsCollector, get_stats_collector
28
+ from .metrics import (
29
+ get_metrics,
30
+ record_tool_call_metric,
31
+ record_tool_error_metric,
32
+ record_tool_duration_metric,
33
+ is_prometheus_available,
34
+ )
35
+ from .tracing import (
36
+ init_tracing,
37
+ traced_tool_call,
38
+ get_tracer,
39
+ is_tracing_available,
40
+ shutdown_tracing,
41
+ )
42
+
43
+ __all__ = [
44
+ # Stats
45
+ "StatsCollector",
46
+ "get_stats_collector",
47
+ # Metrics
48
+ "get_metrics",
49
+ "record_tool_call_metric",
50
+ "record_tool_error_metric",
51
+ "record_tool_duration_metric",
52
+ "is_prometheus_available",
53
+ # Tracing
54
+ "init_tracing",
55
+ "traced_tool_call",
56
+ "get_tracer",
57
+ "is_tracing_available",
58
+ "shutdown_tracing",
59
+ ]