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,247 @@
1
+ """CRD Auto-Discovery Framework for kubectl-mcp-server.
2
+
3
+ Detects installed CRDs in the cluster and enables/disables toolsets accordingly.
4
+ """
5
+
6
+ import subprocess
7
+ import json
8
+ from typing import Dict, List, Optional, Set
9
+ from functools import lru_cache
10
+ import time
11
+
12
+ from .k8s_config import _get_kubectl_context_args
13
+
14
+
15
+ CRD_GROUPS = {
16
+ "flux": [
17
+ "kustomizations.kustomize.toolkit.fluxcd.io",
18
+ "helmreleases.helm.toolkit.fluxcd.io",
19
+ "gitrepositories.source.toolkit.fluxcd.io",
20
+ "helmrepositories.source.toolkit.fluxcd.io",
21
+ ],
22
+ "argocd": [
23
+ "applications.argoproj.io",
24
+ "applicationsets.argoproj.io",
25
+ "appprojects.argoproj.io",
26
+ ],
27
+ "certmanager": [
28
+ "certificates.cert-manager.io",
29
+ "issuers.cert-manager.io",
30
+ "clusterissuers.cert-manager.io",
31
+ "certificaterequests.cert-manager.io",
32
+ "orders.acme.cert-manager.io",
33
+ "challenges.acme.cert-manager.io",
34
+ ],
35
+ "kyverno": [
36
+ "clusterpolicies.kyverno.io",
37
+ "policies.kyverno.io",
38
+ "policyreports.wgpolicyk8s.io",
39
+ "clusterpolicyreports.wgpolicyk8s.io",
40
+ ],
41
+ "gatekeeper": [
42
+ "constrainttemplates.templates.gatekeeper.sh",
43
+ "configs.config.gatekeeper.sh",
44
+ ],
45
+ "velero": [
46
+ "backups.velero.io",
47
+ "restores.velero.io",
48
+ "schedules.velero.io",
49
+ "backupstoragelocations.velero.io",
50
+ ],
51
+ "keda": [
52
+ "scaledobjects.keda.sh",
53
+ "scaledjobs.keda.sh",
54
+ "triggerauthentications.keda.sh",
55
+ ],
56
+ "cilium": [
57
+ "ciliumnetworkpolicies.cilium.io",
58
+ "ciliumclusterwidenetworkpolicies.cilium.io",
59
+ "ciliumendpoints.cilium.io",
60
+ ],
61
+ "istio": [
62
+ "virtualservices.networking.istio.io",
63
+ "destinationrules.networking.istio.io",
64
+ "gateways.networking.istio.io",
65
+ ],
66
+ "argorollouts": [
67
+ "rollouts.argoproj.io",
68
+ "analysistemplates.argoproj.io",
69
+ ],
70
+ "kubevirt": [
71
+ "virtualmachines.kubevirt.io",
72
+ "virtualmachineinstances.kubevirt.io",
73
+ ],
74
+ "capi": [
75
+ "clusters.cluster.x-k8s.io",
76
+ "machines.cluster.x-k8s.io",
77
+ "machinedeployments.cluster.x-k8s.io",
78
+ ],
79
+ }
80
+
81
+
82
+ _crd_cache: Dict[str, Dict[str, bool]] = {}
83
+ _cache_timestamp: Dict[str, float] = {}
84
+ CACHE_TTL = 300
85
+
86
+
87
+ def _get_cluster_crds(context: str = "") -> Set[str]:
88
+ """Get all CRDs installed in the cluster."""
89
+ try:
90
+ cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
91
+ "get", "crds", "-o", "jsonpath={.items[*].metadata.name}"
92
+ ]
93
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
94
+ if result.returncode == 0:
95
+ return set(result.stdout.split())
96
+ return set()
97
+ except Exception:
98
+ return set()
99
+
100
+
101
+ def detect_crds(context: str = "", force_refresh: bool = False) -> Dict[str, bool]:
102
+ """Detect which CRD groups are installed in the cluster.
103
+
104
+ Args:
105
+ context: Kubernetes context to use
106
+ force_refresh: Force refresh the cache
107
+
108
+ Returns:
109
+ Dict mapping CRD group name to installed status
110
+ """
111
+ cache_key = context or "default"
112
+
113
+ if not force_refresh and cache_key in _crd_cache:
114
+ if time.time() - _cache_timestamp.get(cache_key, 0) < CACHE_TTL:
115
+ return _crd_cache[cache_key]
116
+
117
+ installed_crds = _get_cluster_crds(context)
118
+
119
+ result = {}
120
+ for group_name, crds in CRD_GROUPS.items():
121
+ result[group_name] = any(crd in installed_crds for crd in crds)
122
+
123
+ _crd_cache[cache_key] = result
124
+ _cache_timestamp[cache_key] = time.time()
125
+
126
+ return result
127
+
128
+
129
+ def crd_exists(crd_name: str, context: str = "") -> bool:
130
+ """Check if a specific CRD exists in the cluster.
131
+
132
+ Args:
133
+ crd_name: Full CRD name (e.g., "certificates.cert-manager.io")
134
+ context: Kubernetes context to use
135
+
136
+ Returns:
137
+ True if CRD exists, False otherwise
138
+ """
139
+ try:
140
+ cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
141
+ "get", "crd", crd_name, "-o", "name"
142
+ ]
143
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
144
+ return result.returncode == 0
145
+ except Exception:
146
+ return False
147
+
148
+
149
+ def get_enabled_toolsets(context: str = "") -> List[str]:
150
+ """Get list of toolsets that should be enabled based on detected CRDs.
151
+
152
+ Args:
153
+ context: Kubernetes context to use
154
+
155
+ Returns:
156
+ List of enabled toolset names
157
+ """
158
+ crds = detect_crds(context)
159
+ enabled = []
160
+
161
+ if crds.get("flux") or crds.get("argocd"):
162
+ enabled.append("gitops")
163
+ if crds.get("certmanager"):
164
+ enabled.append("certs")
165
+ if crds.get("kyverno") or crds.get("gatekeeper"):
166
+ enabled.append("policy")
167
+ if crds.get("velero"):
168
+ enabled.append("backup")
169
+ if crds.get("keda"):
170
+ enabled.append("keda")
171
+ if crds.get("cilium"):
172
+ enabled.append("cilium")
173
+ if crds.get("argorollouts"):
174
+ enabled.append("rollouts")
175
+ if crds.get("kubevirt"):
176
+ enabled.append("kubevirt")
177
+ if crds.get("capi"):
178
+ enabled.append("capi")
179
+ if crds.get("istio"):
180
+ enabled.append("istio")
181
+
182
+ return enabled
183
+
184
+
185
+ def get_crd_status_summary(context: str = "") -> Dict:
186
+ """Get a summary of CRD detection status.
187
+
188
+ Args:
189
+ context: Kubernetes context to use
190
+
191
+ Returns:
192
+ Summary dict with detected CRDs and enabled toolsets
193
+ """
194
+ crds = detect_crds(context)
195
+ enabled = get_enabled_toolsets(context)
196
+
197
+ return {
198
+ "context": context or "current",
199
+ "crd_groups": crds,
200
+ "enabled_toolsets": enabled,
201
+ "total_groups_detected": sum(1 for v in crds.values() if v),
202
+ "total_toolsets_enabled": len(enabled),
203
+ }
204
+
205
+
206
+ class FeatureNotInstalledError(Exception):
207
+ """Raised when required CRDs are not installed."""
208
+
209
+ def __init__(self, toolset: str, required_crds: List[str]):
210
+ self.toolset = toolset
211
+ self.required_crds = required_crds
212
+ super().__init__(
213
+ f"{toolset} toolset requires one of these CRDs: {', '.join(required_crds)}. "
214
+ f"Install the required operator to use this feature."
215
+ )
216
+
217
+
218
+ def require_crd(crd_name: str, toolset: str, context: str = ""):
219
+ """Check if a CRD exists and raise an error if not.
220
+
221
+ Args:
222
+ crd_name: CRD name to check
223
+ toolset: Toolset name for error message
224
+ context: Kubernetes context
225
+
226
+ Raises:
227
+ FeatureNotInstalledError: If CRD is not installed
228
+ """
229
+ if not crd_exists(crd_name, context):
230
+ raise FeatureNotInstalledError(toolset, [crd_name])
231
+
232
+
233
+ def require_any_crd(crd_names: List[str], toolset: str, context: str = ""):
234
+ """Check if any of the CRDs exist and raise an error if none are found.
235
+
236
+ Args:
237
+ crd_names: List of CRD names to check
238
+ toolset: Toolset name for error message
239
+ context: Kubernetes context
240
+
241
+ Raises:
242
+ FeatureNotInstalledError: If no CRDs are installed
243
+ """
244
+ for crd in crd_names:
245
+ if crd_exists(crd, context):
246
+ return
247
+ raise FeatureNotInstalledError(toolset, crd_names)
@@ -509,3 +509,22 @@ def context_exists(context: str) -> bool:
509
509
  """
510
510
  contexts = list_contexts()
511
511
  return any(ctx["name"] == context for ctx in contexts)
512
+
513
+
514
+ def _get_kubectl_context_args(context: str = "") -> list:
515
+ """
516
+ Get kubectl command arguments for specifying a context.
517
+
518
+ This utility function returns the appropriate --context flag arguments
519
+ for kubectl commands when targeting a specific cluster.
520
+
521
+ Args:
522
+ context: Context name (empty string for default context)
523
+
524
+ Returns:
525
+ List of command arguments, e.g., ["--context", "my-cluster"]
526
+ or empty list if no context specified
527
+ """
528
+ if context and context.strip():
529
+ return ["--context", context.strip()]
530
+ return []
@@ -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,
@@ -45,6 +82,16 @@ from kubectl_mcp_tool.tools import (
45
82
  is_browser_available,
46
83
  register_ui_tools,
47
84
  is_ui_available,
85
+ register_gitops_tools,
86
+ register_certs_tools,
87
+ register_policy_tools,
88
+ register_backup_tools,
89
+ register_keda_tools,
90
+ register_cilium_tools,
91
+ register_rollouts_tools,
92
+ register_capi_tools,
93
+ register_kubevirt_tools,
94
+ register_istio_tools,
48
95
  )
49
96
  from kubectl_mcp_tool.resources import register_resources
50
97
  from kubectl_mcp_tool.prompts import register_prompts
@@ -106,12 +153,20 @@ except ImportError:
106
153
  class MCPServer:
107
154
  """MCP server implementation."""
108
155
 
109
- 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
+ ):
110
163
  """Initialize the MCP server.
111
164
 
112
165
  Args:
113
166
  name: Server name for identification
114
- 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
115
170
 
116
171
  Environment Variables:
117
172
  MCP_AUTH_ENABLED: Enable OAuth 2.1 authentication (default: false)
@@ -121,9 +176,29 @@ class MCPServer:
121
176
  MCP_AUTH_REQUIRED_SCOPES: Required scopes (default: mcp:tools)
122
177
  """
123
178
  self.name = name
124
- self.non_destructive = non_destructive
125
179
  self._dependencies_checked = False
126
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()
127
202
 
128
203
  # Load authentication configuration
129
204
  self.auth_config = get_auth_config()
@@ -140,6 +215,71 @@ class MCPServer:
140
215
  self.setup_resources()
141
216
  self.setup_prompts()
142
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
+
143
283
  def _setup_auth(self) -> Optional[Any]:
144
284
  """Set up authentication if enabled."""
145
285
  if not self.auth_config.enabled:
@@ -196,13 +336,34 @@ class MCPServer:
196
336
  else:
197
337
  logger.debug("MCP-UI tools disabled (install mcp-ui-server to enable)")
198
338
 
339
+ # Register ecosystem tools (GitOps, Cert-Manager, Policy, Backup)
340
+ # These tools auto-detect installed CRDs and gracefully handle missing components
341
+ register_gitops_tools(self.server, self.non_destructive)
342
+ register_certs_tools(self.server, self.non_destructive)
343
+ register_policy_tools(self.server, self.non_destructive)
344
+ register_backup_tools(self.server, self.non_destructive)
345
+ logger.debug("Ecosystem tools registered (GitOps, Certs, Policy, Backup)")
346
+
347
+ # Register advanced ecosystem tools (KEDA, Cilium, Rollouts, CAPI, KubeVirt, Istio)
348
+ register_keda_tools(self.server, self.non_destructive)
349
+ register_cilium_tools(self.server, self.non_destructive)
350
+ register_rollouts_tools(self.server, self.non_destructive)
351
+ register_capi_tools(self.server, self.non_destructive)
352
+ register_kubevirt_tools(self.server, self.non_destructive)
353
+ register_istio_tools(self.server, self.non_destructive)
354
+ logger.debug("Advanced ecosystem tools registered (KEDA, Cilium, Rollouts, CAPI, KubeVirt, Istio)")
355
+
199
356
  def setup_resources(self):
200
357
  """Set up MCP resources for Kubernetes data exposure."""
201
358
  register_resources(self.server)
202
359
 
203
360
  def setup_prompts(self):
204
- """Set up MCP prompts."""
205
- 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)
206
367
 
207
368
  def _check_dependencies(self) -> bool:
208
369
  """Check if required dependencies are available."""
@@ -331,17 +492,51 @@ class MCPServer:
331
492
  try:
332
493
  # FastMCP 3 uses create_sse_app() to create a Starlette ASGI app
333
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)
334
517
 
335
518
  # Create the SSE Starlette application
336
519
  # message_path: POST endpoint for client messages
337
520
  # sse_path: GET endpoint for SSE event stream
338
- app = create_sse_app(
521
+ sse_app = create_sse_app(
339
522
  self.server,
340
523
  message_path="/messages/",
341
524
  sse_path="/sse"
342
525
  )
343
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
+
344
538
  logger.info(f"SSE endpoints: GET /sse (events), POST /messages/ (messages)")
539
+ logger.info(f"Observability endpoints: GET /health, /stats, /metrics, /safety")
345
540
 
346
541
  # Run with uvicorn
347
542
  config = uvicorn.Config(app, host=host, port=port, log_level="info")
@@ -471,11 +666,33 @@ class MCPServer:
471
666
  """Health check endpoint."""
472
667
  return JSONResponse({"status": "healthy", "server": self.name})
473
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
+
474
688
  app = Starlette(
475
689
  routes=[
476
690
  Route("/", handle_mcp_request, methods=["POST"]),
477
691
  Route("/mcp", handle_mcp_request, methods=["POST"]),
478
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"]),
479
696
  ]
480
697
  )
481
698
 
@@ -508,10 +725,31 @@ if __name__ == "__main__":
508
725
  default="0.0.0.0",
509
726
  help="Host to bind to for SSE/HTTP transport. Default: 0.0.0.0.",
510
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
+ )
511
744
  args = parser.parse_args()
512
745
 
513
746
  server_name = "kubectl_mcp_server"
514
- 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
+ )
515
753
 
516
754
  # Handle signals gracefully with immediate exit
517
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
+ ]