kubectl-mcp-server 1.19.0__py3-none-any.whl → 1.19.1__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.
@@ -71,6 +71,7 @@ from kubectl_mcp_tool.tools import (
71
71
  register_pod_tools,
72
72
  register_core_tools,
73
73
  register_cluster_tools,
74
+ register_multicluster_tools,
74
75
  register_deployment_tools,
75
76
  register_security_tools,
76
77
  register_networking_tools,
@@ -314,6 +315,7 @@ class MCPServer:
314
315
  register_pod_tools(self.server, self.non_destructive)
315
316
  register_core_tools(self.server, self.non_destructive)
316
317
  register_cluster_tools(self.server, self.non_destructive)
318
+ register_multicluster_tools(self.server, self.non_destructive)
317
319
  register_deployment_tools(self.server, self.non_destructive)
318
320
  register_security_tools(self.server, self.non_destructive)
319
321
  register_networking_tools(self.server, self.non_destructive)
@@ -741,8 +743,36 @@ if __name__ == "__main__":
741
743
  action="store_true",
742
744
  help="Disable destructive operations (allow create/update, block delete).",
743
745
  )
746
+ parser.add_argument(
747
+ "--stateless",
748
+ action="store_true",
749
+ help="Enable stateless mode (don't cache API clients, reload config each request). Useful for serverless environments.",
750
+ )
751
+ parser.add_argument(
752
+ "--watch-kubeconfig",
753
+ action="store_true",
754
+ help="Watch kubeconfig files for changes and auto-reload. Useful when credentials are refreshed externally.",
755
+ )
756
+ parser.add_argument(
757
+ "--watch-interval",
758
+ type=float,
759
+ default=5.0,
760
+ help="Interval in seconds for kubeconfig watch checks. Default: 5.0.",
761
+ )
744
762
  args = parser.parse_args()
745
763
 
764
+ # Configure stateless mode if requested
765
+ if args.stateless or os.environ.get("MCP_STATELESS_MODE", "").lower() in ("true", "1", "yes"):
766
+ from kubectl_mcp_tool.k8s_config import set_stateless_mode
767
+ set_stateless_mode(True)
768
+ logger.info("Stateless mode enabled via CLI flag")
769
+
770
+ # Configure kubeconfig watching if requested
771
+ if args.watch_kubeconfig or os.environ.get("MCP_KUBECONFIG_WATCH", "").lower() in ("true", "1", "yes"):
772
+ from kubectl_mcp_tool.k8s_config import enable_kubeconfig_watch
773
+ enable_kubeconfig_watch(check_interval=args.watch_interval)
774
+ logger.info(f"Kubeconfig watching enabled (interval: {args.watch_interval}s)")
775
+
746
776
  server_name = "kubectl_mcp_server"
747
777
  mcp_server = MCPServer(
748
778
  name=server_name,
@@ -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)
@@ -1,7 +1,7 @@
1
1
  from .helm import register_helm_tools
2
2
  from .pods import register_pod_tools
3
3
  from .core import register_core_tools
4
- from .cluster import register_cluster_tools
4
+ from .cluster import register_cluster_tools, register_multicluster_tools
5
5
  from .deployments import register_deployment_tools
6
6
  from .security import register_security_tools
7
7
  from .networking import register_networking_tools
@@ -27,6 +27,7 @@ __all__ = [
27
27
  "register_pod_tools",
28
28
  "register_core_tools",
29
29
  "register_cluster_tools",
30
+ "register_multicluster_tools",
30
31
  "register_deployment_tools",
31
32
  "register_security_tools",
32
33
  "register_networking_tools",
@@ -1,11 +1,8 @@
1
- """Backup toolset for kubectl-mcp-server.
2
-
3
- Provides tools for managing Velero backups and restores.
4
- """
1
+ """Backup toolset for kubectl-mcp-server (Velero backups and restores)."""
5
2
 
6
3
  import subprocess
7
4
  import json
8
- from typing import Dict, Any, List, Optional
5
+ from typing import Dict, Any, List
9
6
  from datetime import datetime
10
7
 
11
8
  try:
@@ -15,8 +12,8 @@ except ImportError:
15
12
  from mcp.server.fastmcp import FastMCP
16
13
  from mcp.types import ToolAnnotations
17
14
 
18
- from ..k8s_config import _get_kubectl_context_args
19
15
  from ..crd_detector import crd_exists
16
+ from .utils import run_kubectl, get_resources
20
17
 
21
18
 
22
19
  VELERO_BACKUP_CRD = "backups.velero.io"
@@ -26,40 +23,6 @@ VELERO_BSL_CRD = "backupstoragelocations.velero.io"
26
23
  VELERO_VSL_CRD = "volumesnapshotlocations.velero.io"
27
24
 
28
25
 
29
- def _run_kubectl(args: List[str], context: str = "") -> Dict[str, Any]:
30
- """Run kubectl command and return result."""
31
- cmd = ["kubectl"] + _get_kubectl_context_args(context) + args
32
- try:
33
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
34
- if result.returncode == 0:
35
- return {"success": True, "output": result.stdout}
36
- return {"success": False, "error": result.stderr}
37
- except subprocess.TimeoutExpired:
38
- return {"success": False, "error": "Command timed out"}
39
- except Exception as e:
40
- return {"success": False, "error": str(e)}
41
-
42
-
43
- def _get_resources(kind: str, namespace: str = "", context: str = "", label_selector: str = "") -> List[Dict]:
44
- """Get Kubernetes resources of a specific kind."""
45
- args = ["get", kind, "-o", "json"]
46
- if namespace:
47
- args.extend(["-n", namespace])
48
- else:
49
- args.append("-A")
50
- if label_selector:
51
- args.extend(["-l", label_selector])
52
-
53
- result = _run_kubectl(args, context)
54
- if result["success"]:
55
- try:
56
- data = json.loads(result["output"])
57
- return data.get("items", [])
58
- except json.JSONDecodeError:
59
- return []
60
- return []
61
-
62
-
63
26
  def _velero_cli_available() -> bool:
64
27
  """Check if velero CLI is available."""
65
28
  try:
@@ -112,7 +75,7 @@ def backup_list(
112
75
  }
113
76
 
114
77
  backups = []
115
- for item in _get_resources("backups.velero.io", namespace, context, label_selector):
78
+ for item in get_resources("backups.velero.io", namespace, context, label_selector):
116
79
  status = item.get("status", {})
117
80
  spec = item.get("spec", {})
118
81
  progress = status.get("progress", {})
@@ -165,7 +128,7 @@ def backup_get(
165
128
  return {"success": False, "error": "Velero is not installed"}
166
129
 
167
130
  args = ["get", "backups.velero.io", name, "-n", namespace, "-o", "json"]
168
- result = _run_kubectl(args, context)
131
+ result = run_kubectl(args, context)
169
132
 
170
133
  if result["success"]:
171
134
  try:
@@ -341,7 +304,7 @@ def backup_delete(
341
304
  return result
342
305
 
343
306
  args = ["delete", "backups.velero.io", name, "-n", namespace]
344
- result = _run_kubectl(args, context)
307
+ result = run_kubectl(args, context)
345
308
 
346
309
  if result["success"]:
347
310
  return {
@@ -375,7 +338,7 @@ def restore_list(
375
338
  }
376
339
 
377
340
  restores = []
378
- for item in _get_resources("restores.velero.io", namespace, context, label_selector):
341
+ for item in get_resources("restores.velero.io", namespace, context, label_selector):
379
342
  status = item.get("status", {})
380
343
  spec = item.get("spec", {})
381
344
  progress = status.get("progress", {})
@@ -536,7 +499,7 @@ def restore_get(
536
499
  return {"success": False, "error": "Velero is not installed"}
537
500
 
538
501
  args = ["get", "restores.velero.io", name, "-n", namespace, "-o", "json"]
539
- result = _run_kubectl(args, context)
502
+ result = run_kubectl(args, context)
540
503
 
541
504
  if result["success"]:
542
505
  try:
@@ -572,7 +535,7 @@ def backup_locations_list(
572
535
  }
573
536
 
574
537
  locations = []
575
- for item in _get_resources("backupstoragelocations.velero.io", namespace, context):
538
+ for item in get_resources("backupstoragelocations.velero.io", namespace, context):
576
539
  status = item.get("status", {})
577
540
  spec = item.get("spec", {})
578
541
 
@@ -615,7 +578,7 @@ def backup_schedules_list(
615
578
  }
616
579
 
617
580
  schedules = []
618
- for item in _get_resources("schedules.velero.io", namespace, context):
581
+ for item in get_resources("schedules.velero.io", namespace, context):
619
582
  status = item.get("status", {})
620
583
  spec = item.get("spec", {})
621
584
  template = spec.get("template", {})