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
@@ -0,0 +1,347 @@
1
+ import os
2
+ import logging
3
+ from enum import Enum
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+ from dataclasses import dataclass, field
6
+ from functools import lru_cache
7
+
8
+ logger = logging.getLogger("mcp-server")
9
+
10
+
11
+ class ProviderType(Enum):
12
+ """Kubernetes provider types."""
13
+ KUBECONFIG = "kubeconfig"
14
+ IN_CLUSTER = "in-cluster"
15
+ SINGLE = "single"
16
+
17
+
18
+ class UnknownContextError(Exception):
19
+ """Raised when a requested context is not found."""
20
+ def __init__(self, context: str, available: List[str] = None):
21
+ self.context = context
22
+ self.available = available or []
23
+ msg = f"Context '{context}' not found"
24
+ if self.available:
25
+ msg += f". Available contexts: {', '.join(self.available)}"
26
+ super().__init__(msg)
27
+
28
+
29
+ class ProviderError(Exception):
30
+ """Raised when provider configuration is invalid."""
31
+ pass
32
+
33
+
34
+ @dataclass
35
+ class ProviderConfig:
36
+ """Configuration for Kubernetes provider."""
37
+ provider_type: ProviderType = ProviderType.KUBECONFIG
38
+ kubeconfig_path: str = ""
39
+ context: str = ""
40
+ qps: float = 100.0
41
+ burst: int = 200
42
+ timeout: int = 30
43
+
44
+ @classmethod
45
+ def from_env(cls) -> "ProviderConfig":
46
+ """Create config from environment variables."""
47
+ provider_str = os.environ.get("MCP_K8S_PROVIDER", "kubeconfig").lower()
48
+
49
+ try:
50
+ provider_type = ProviderType(provider_str)
51
+ except ValueError:
52
+ logger.warning(f"Unknown provider type '{provider_str}', using kubeconfig")
53
+ provider_type = ProviderType.KUBECONFIG
54
+
55
+ kubeconfig_path = os.environ.get(
56
+ "MCP_K8S_KUBECONFIG",
57
+ os.environ.get("KUBECONFIG", "~/.kube/config")
58
+ )
59
+ kubeconfig_path = os.path.expanduser(kubeconfig_path)
60
+
61
+ return cls(
62
+ provider_type=provider_type,
63
+ kubeconfig_path=kubeconfig_path,
64
+ context=os.environ.get("MCP_K8S_CONTEXT", ""),
65
+ qps=float(os.environ.get("MCP_K8S_QPS", "100")),
66
+ burst=int(os.environ.get("MCP_K8S_BURST", "200")),
67
+ timeout=int(os.environ.get("MCP_K8S_TIMEOUT", "30")),
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class ContextInfo:
73
+ """Information about a kubeconfig context."""
74
+ name: str
75
+ cluster: str
76
+ user: str
77
+ namespace: str = "default"
78
+ is_active: bool = False
79
+
80
+
81
+ class KubernetesProvider:
82
+ """
83
+ Multi-cluster Kubernetes provider.
84
+
85
+ Manages connections to multiple Kubernetes clusters based on
86
+ kubeconfig contexts or in-cluster configuration.
87
+ """
88
+
89
+ _instance: Optional["KubernetesProvider"] = None
90
+
91
+ def __init__(self, config: Optional[ProviderConfig] = None):
92
+ """Initialize provider with configuration."""
93
+ self.config = config or ProviderConfig.from_env()
94
+ self._api_clients: Dict[str, Any] = {}
95
+ self._in_cluster = False
96
+ self._contexts_cache: Optional[List[ContextInfo]] = None
97
+ self._active_context: Optional[str] = None
98
+
99
+ self._initialize()
100
+
101
+ @classmethod
102
+ def get_instance(cls) -> "KubernetesProvider":
103
+ """Get singleton provider instance."""
104
+ if cls._instance is None:
105
+ cls._instance = cls()
106
+ return cls._instance
107
+
108
+ @classmethod
109
+ def reset_instance(cls):
110
+ """Reset singleton instance (for testing)."""
111
+ cls._instance = None
112
+
113
+ def _initialize(self):
114
+ """Initialize the provider based on type."""
115
+ if self.config.provider_type == ProviderType.IN_CLUSTER:
116
+ self._initialize_in_cluster()
117
+ elif self.config.provider_type == ProviderType.SINGLE:
118
+ self._initialize_single()
119
+ else:
120
+ self._initialize_kubeconfig()
121
+
122
+ def _initialize_in_cluster(self):
123
+ """Initialize for in-cluster provider."""
124
+ from kubernetes import config
125
+ from kubernetes.config.config_exception import ConfigException
126
+
127
+ try:
128
+ config.load_incluster_config()
129
+ self._in_cluster = True
130
+ self._active_context = "in-cluster"
131
+ logger.info("Initialized in-cluster Kubernetes provider")
132
+ except ConfigException as e:
133
+ raise ProviderError(f"Failed to load in-cluster config: {e}")
134
+
135
+ def _initialize_single(self):
136
+ """Initialize for single-context provider."""
137
+ if not self.config.context:
138
+ raise ProviderError("MCP_K8S_CONTEXT must be set for 'single' provider")
139
+
140
+ from kubernetes import config
141
+
142
+ try:
143
+ config.load_kube_config(
144
+ config_file=self.config.kubeconfig_path,
145
+ context=self.config.context
146
+ )
147
+ self._active_context = self.config.context
148
+ logger.info(f"Initialized single-context provider: {self.config.context}")
149
+ except Exception as e:
150
+ raise ProviderError(f"Failed to load context '{self.config.context}': {e}")
151
+
152
+ def _initialize_kubeconfig(self):
153
+ """Initialize for multi-cluster kubeconfig provider."""
154
+ from kubernetes import config
155
+
156
+ try:
157
+ contexts, active = config.list_kube_config_contexts(
158
+ config_file=self.config.kubeconfig_path
159
+ )
160
+
161
+ if active:
162
+ self._active_context = active.get("name")
163
+
164
+ self._contexts_cache = [
165
+ ContextInfo(
166
+ name=ctx.get("name", ""),
167
+ cluster=ctx.get("context", {}).get("cluster", ""),
168
+ user=ctx.get("context", {}).get("user", ""),
169
+ namespace=ctx.get("context", {}).get("namespace", "default"),
170
+ is_active=ctx.get("name") == self._active_context
171
+ )
172
+ for ctx in contexts
173
+ ]
174
+
175
+ logger.info(
176
+ f"Initialized kubeconfig provider with {len(self._contexts_cache)} contexts, "
177
+ f"active: {self._active_context}"
178
+ )
179
+ except Exception as e:
180
+ logger.warning(f"Failed to list contexts: {e}")
181
+ self._contexts_cache = []
182
+
183
+ def list_contexts(self) -> List[ContextInfo]:
184
+ """
185
+ List all available contexts.
186
+
187
+ Returns:
188
+ List of ContextInfo objects
189
+ """
190
+ if self._in_cluster:
191
+ return [ContextInfo(
192
+ name="in-cluster",
193
+ cluster="in-cluster",
194
+ user="service-account",
195
+ namespace="default",
196
+ is_active=True
197
+ )]
198
+
199
+ if self.config.provider_type == ProviderType.SINGLE:
200
+ from kubernetes import config
201
+ try:
202
+ contexts, _ = config.list_kube_config_contexts(
203
+ config_file=self.config.kubeconfig_path
204
+ )
205
+ for ctx in contexts:
206
+ if ctx.get("name") == self.config.context:
207
+ return [ContextInfo(
208
+ name=ctx.get("name", ""),
209
+ cluster=ctx.get("context", {}).get("cluster", ""),
210
+ user=ctx.get("context", {}).get("user", ""),
211
+ namespace=ctx.get("context", {}).get("namespace", "default"),
212
+ is_active=True
213
+ )]
214
+ except Exception:
215
+ pass
216
+ return []
217
+
218
+ self._refresh_contexts_cache()
219
+ return self._contexts_cache or []
220
+
221
+ def _refresh_contexts_cache(self):
222
+ """Refresh the contexts cache from kubeconfig."""
223
+ if self._in_cluster or self.config.provider_type == ProviderType.SINGLE:
224
+ return
225
+
226
+ from kubernetes import config
227
+ try:
228
+ contexts, active = config.list_kube_config_contexts(
229
+ config_file=self.config.kubeconfig_path
230
+ )
231
+ self._active_context = active.get("name") if active else None
232
+ self._contexts_cache = [
233
+ ContextInfo(
234
+ name=ctx.get("name", ""),
235
+ cluster=ctx.get("context", {}).get("cluster", ""),
236
+ user=ctx.get("context", {}).get("user", ""),
237
+ namespace=ctx.get("context", {}).get("namespace", "default"),
238
+ is_active=ctx.get("name") == self._active_context
239
+ )
240
+ for ctx in contexts
241
+ ]
242
+ except Exception as e:
243
+ logger.warning(f"Failed to refresh contexts: {e}")
244
+
245
+ def get_current_context(self) -> Optional[str]:
246
+ """Get the current active context name."""
247
+ if self._in_cluster:
248
+ return "in-cluster"
249
+ return self._active_context
250
+
251
+ def _get_context_names(self) -> List[str]:
252
+ """Get list of available context names."""
253
+ contexts = self.list_contexts()
254
+ return [ctx.name for ctx in contexts]
255
+
256
+ def validate_context(self, context: str) -> str:
257
+ """
258
+ Validate and resolve a context name.
259
+
260
+ Args:
261
+ context: Context name (empty string uses default)
262
+
263
+ Returns:
264
+ Resolved context name
265
+
266
+ Raises:
267
+ UnknownContextError: If context is not found
268
+ """
269
+ if self._in_cluster:
270
+ return "in-cluster"
271
+
272
+ if self.config.provider_type == ProviderType.SINGLE:
273
+ if context and context != self.config.context:
274
+ raise UnknownContextError(
275
+ context,
276
+ [self.config.context]
277
+ )
278
+ return self.config.context
279
+
280
+ if not context:
281
+ return self._active_context or ""
282
+
283
+ available = self._get_context_names()
284
+ if context not in available:
285
+ raise UnknownContextError(context, available)
286
+
287
+ return context
288
+
289
+ def get_api_client(self, context: str = "") -> Any:
290
+ """
291
+ Get an API client configuration for a specific context.
292
+
293
+ Args:
294
+ context: Context name (empty uses default)
295
+
296
+ Returns:
297
+ kubernetes.client.ApiClient configured for the context
298
+ """
299
+ from kubernetes import client, config
300
+
301
+ resolved_context = self.validate_context(context)
302
+
303
+ if resolved_context in self._api_clients:
304
+ return self._api_clients[resolved_context]
305
+
306
+ if self._in_cluster:
307
+ api_client = client.ApiClient()
308
+ else:
309
+ api_config = client.Configuration()
310
+ config.load_kube_config(
311
+ config_file=self.config.kubeconfig_path,
312
+ context=resolved_context,
313
+ client_configuration=api_config
314
+ )
315
+ api_client = client.ApiClient(configuration=api_config)
316
+
317
+ self._api_clients[resolved_context] = api_client
318
+
319
+ return api_client
320
+
321
+ def clear_client_cache(self, context: str = ""):
322
+ """Clear cached API client(s)."""
323
+ if context:
324
+ self._api_clients.pop(context, None)
325
+ else:
326
+ self._api_clients.clear()
327
+
328
+
329
+ def get_provider() -> KubernetesProvider:
330
+ """Get the global Kubernetes provider instance."""
331
+ return KubernetesProvider.get_instance()
332
+
333
+
334
+ def get_context_names() -> List[str]:
335
+ """Get list of available context names."""
336
+ provider = get_provider()
337
+ return [ctx.name for ctx in provider.list_contexts()]
338
+
339
+
340
+ def get_current_context() -> Optional[str]:
341
+ """Get the current active context name."""
342
+ return get_provider().get_current_context()
343
+
344
+
345
+ def validate_context(context: str) -> str:
346
+ """Validate and resolve a context name."""
347
+ return get_provider().validate_context(context)
@@ -0,0 +1,155 @@
1
+ """
2
+ Safety mode implementation for kubectl-mcp-server.
3
+
4
+ Provides read-only and disable-destructive modes to prevent accidental cluster mutations.
5
+ """
6
+
7
+ from enum import Enum
8
+ from functools import wraps
9
+ from typing import Any, Callable, Dict, Set
10
+ import logging
11
+
12
+ logger = logging.getLogger("mcp-server")
13
+
14
+
15
+ class SafetyMode(Enum):
16
+ """Safety mode levels for the MCP server."""
17
+ NORMAL = "normal"
18
+ READ_ONLY = "read_only"
19
+ DISABLE_DESTRUCTIVE = "disable_destructive"
20
+
21
+
22
+ # Global safety mode state
23
+ _current_mode: SafetyMode = SafetyMode.NORMAL
24
+
25
+
26
+ # Operations that modify cluster state (blocked in READ_ONLY mode)
27
+ WRITE_OPERATIONS: Set[str] = {
28
+ # Pod operations
29
+ "run_pod", "delete_pod",
30
+ # Deployment operations
31
+ "scale_deployment", "restart_deployment", "delete_deployment",
32
+ "rollback_deployment", "create_deployment", "update_deployment",
33
+ # StatefulSet operations
34
+ "scale_statefulset", "restart_statefulset", "delete_statefulset",
35
+ # DaemonSet operations
36
+ "restart_daemonset", "delete_daemonset",
37
+ # Service operations
38
+ "create_service", "delete_service", "update_service",
39
+ # ConfigMap/Secret operations
40
+ "create_configmap", "delete_configmap", "update_configmap",
41
+ "create_secret", "delete_secret", "update_secret",
42
+ # Namespace operations
43
+ "create_namespace", "delete_namespace",
44
+ # Helm operations
45
+ "install_helm_chart", "upgrade_helm_chart", "uninstall_helm_chart",
46
+ "rollback_helm_release",
47
+ # kubectl operations
48
+ "apply_manifest", "delete_resource", "patch_resource",
49
+ "create_resource", "replace_resource",
50
+ # Context operations
51
+ "switch_context", "set_namespace_for_context",
52
+ # Rollout operations
53
+ "rollout_promote_tool", "rollout_abort_tool", "rollout_retry_tool",
54
+ "rollout_restart_tool",
55
+ # KubeVirt operations
56
+ "kubevirt_vm_start_tool", "kubevirt_vm_stop_tool", "kubevirt_vm_restart_tool",
57
+ "kubevirt_vm_pause_tool", "kubevirt_vm_unpause_tool", "kubevirt_vm_migrate_tool",
58
+ # CAPI operations
59
+ "capi_machinedeployment_scale_tool",
60
+ }
61
+
62
+ # Operations that are destructive (blocked in DISABLE_DESTRUCTIVE mode)
63
+ DESTRUCTIVE_OPERATIONS: Set[str] = {
64
+ # Delete operations
65
+ "delete_pod", "delete_deployment", "delete_statefulset", "delete_daemonset",
66
+ "delete_service", "delete_configmap", "delete_secret", "delete_namespace",
67
+ "delete_resource",
68
+ # Helm uninstall
69
+ "uninstall_helm_chart",
70
+ # Rollout abort
71
+ "rollout_abort_tool",
72
+ # VM stop
73
+ "kubevirt_vm_stop_tool",
74
+ }
75
+
76
+
77
+ def get_safety_mode() -> SafetyMode:
78
+ """Get the current safety mode."""
79
+ return _current_mode
80
+
81
+
82
+ def set_safety_mode(mode: SafetyMode) -> None:
83
+ """Set the safety mode globally."""
84
+ global _current_mode
85
+ _current_mode = mode
86
+ logger.info(f"Safety mode set to: {mode.value}")
87
+
88
+
89
+ def is_operation_allowed(operation_name: str) -> tuple[bool, str]:
90
+ """
91
+ Check if an operation is allowed under the current safety mode.
92
+
93
+ Returns:
94
+ Tuple of (allowed: bool, reason: str)
95
+ """
96
+ mode = get_safety_mode()
97
+
98
+ if mode == SafetyMode.NORMAL:
99
+ return True, ""
100
+
101
+ if mode == SafetyMode.READ_ONLY:
102
+ if operation_name in WRITE_OPERATIONS or operation_name in DESTRUCTIVE_OPERATIONS:
103
+ return False, f"Operation '{operation_name}' blocked: read-only mode is enabled"
104
+
105
+ if mode == SafetyMode.DISABLE_DESTRUCTIVE:
106
+ if operation_name in DESTRUCTIVE_OPERATIONS:
107
+ return False, f"Operation '{operation_name}' blocked: destructive operations are disabled"
108
+
109
+ return True, ""
110
+
111
+
112
+ def check_safety_mode(func: Callable) -> Callable:
113
+ """
114
+ Decorator to check safety mode before executing a tool function.
115
+
116
+ Usage:
117
+ @check_safety_mode
118
+ def delete_pod(...):
119
+ ...
120
+ """
121
+ @wraps(func)
122
+ def wrapper(*args, **kwargs) -> Dict[str, Any]:
123
+ operation_name = func.__name__
124
+ allowed, reason = is_operation_allowed(operation_name)
125
+
126
+ if not allowed:
127
+ logger.warning(f"Blocked operation: {operation_name} (mode: {get_safety_mode().value})")
128
+ return {
129
+ "success": False,
130
+ "error": reason,
131
+ "blocked_by": get_safety_mode().value,
132
+ "operation": operation_name
133
+ }
134
+
135
+ return func(*args, **kwargs)
136
+
137
+ return wrapper
138
+
139
+
140
+ def get_mode_info() -> Dict[str, Any]:
141
+ """Get information about the current safety mode."""
142
+ mode = get_safety_mode()
143
+ return {
144
+ "mode": mode.value,
145
+ "description": {
146
+ SafetyMode.NORMAL: "All operations allowed",
147
+ SafetyMode.READ_ONLY: "Only read operations allowed (no create/update/delete)",
148
+ SafetyMode.DISABLE_DESTRUCTIVE: "Create/update allowed, delete operations blocked",
149
+ }[mode],
150
+ "blocked_operations": {
151
+ SafetyMode.NORMAL: [],
152
+ SafetyMode.READ_ONLY: sorted(WRITE_OPERATIONS | DESTRUCTIVE_OPERATIONS),
153
+ SafetyMode.DISABLE_DESTRUCTIVE: sorted(DESTRUCTIVE_OPERATIONS),
154
+ }[mode]
155
+ }