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,727 @@
1
+ """Cluster API (CAPI) toolset for kubectl-mcp-server.
2
+
3
+ Provides tools for managing Cluster API clusters and machine lifecycle.
4
+ """
5
+
6
+ import subprocess
7
+ import json
8
+ from typing import Dict, Any, List
9
+
10
+ try:
11
+ from fastmcp import FastMCP
12
+ from fastmcp.tools import ToolAnnotations
13
+ except ImportError:
14
+ from mcp.server.fastmcp import FastMCP
15
+ from mcp.types import ToolAnnotations
16
+
17
+ from ..k8s_config import _get_kubectl_context_args
18
+ from ..crd_detector import crd_exists
19
+
20
+
21
+ # Cluster API CRDs
22
+ CLUSTER_CRD = "clusters.cluster.x-k8s.io"
23
+ MACHINE_CRD = "machines.cluster.x-k8s.io"
24
+ MACHINEDEPLOYMENT_CRD = "machinedeployments.cluster.x-k8s.io"
25
+ MACHINESET_CRD = "machinesets.cluster.x-k8s.io"
26
+ MACHINEPOOL_CRD = "machinepools.cluster.x-k8s.io"
27
+ MACHINEHEALTHCHECK_CRD = "machinehealthchecks.cluster.x-k8s.io"
28
+ CLUSTERCLASS_CRD = "clusterclasses.cluster.x-k8s.io"
29
+
30
+
31
+ def _run_kubectl(args: List[str], context: str = "") -> Dict[str, Any]:
32
+ """Run kubectl command and return result."""
33
+ cmd = ["kubectl"] + _get_kubectl_context_args(context) + args
34
+ try:
35
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
36
+ if result.returncode == 0:
37
+ return {"success": True, "output": result.stdout}
38
+ return {"success": False, "error": result.stderr}
39
+ except subprocess.TimeoutExpired:
40
+ return {"success": False, "error": "Command timed out"}
41
+ except Exception as e:
42
+ return {"success": False, "error": str(e)}
43
+
44
+
45
+ def _get_resources(kind: str, namespace: str = "", context: str = "", label_selector: str = "") -> List[Dict]:
46
+ """Get Kubernetes resources of a specific kind."""
47
+ args = ["get", kind, "-o", "json"]
48
+ if namespace:
49
+ args.extend(["-n", namespace])
50
+ else:
51
+ args.append("-A")
52
+ if label_selector:
53
+ args.extend(["-l", label_selector])
54
+
55
+ result = _run_kubectl(args, context)
56
+ if result["success"]:
57
+ try:
58
+ data = json.loads(result["output"])
59
+ return data.get("items", [])
60
+ except json.JSONDecodeError:
61
+ return []
62
+ return []
63
+
64
+
65
+ def _clusterctl_available() -> bool:
66
+ """Check if clusterctl CLI is available."""
67
+ try:
68
+ result = subprocess.run(["clusterctl", "version"], capture_output=True, timeout=5)
69
+ return result.returncode == 0
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def capi_clusters_list(
75
+ namespace: str = "",
76
+ context: str = "",
77
+ label_selector: str = ""
78
+ ) -> Dict[str, Any]:
79
+ """List Cluster API managed clusters.
80
+
81
+ Args:
82
+ namespace: Filter by namespace (empty for all namespaces)
83
+ context: Kubernetes context to use (optional)
84
+ label_selector: Label selector to filter clusters
85
+
86
+ Returns:
87
+ List of CAPI clusters with their status
88
+ """
89
+ if not crd_exists(CLUSTER_CRD, context):
90
+ return {
91
+ "success": False,
92
+ "error": "Cluster API is not installed (clusters.cluster.x-k8s.io CRD not found)"
93
+ }
94
+
95
+ clusters = []
96
+ for item in _get_resources("clusters.cluster.x-k8s.io", namespace, context, label_selector):
97
+ status = item.get("status", {})
98
+ spec = item.get("spec", {})
99
+ conditions = status.get("conditions", [])
100
+
101
+ # Find key conditions
102
+ ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
103
+ infra_ready = next((c for c in conditions if c.get("type") == "InfrastructureReady"), {})
104
+ cp_ready = next((c for c in conditions if c.get("type") == "ControlPlaneReady"), {})
105
+
106
+ clusters.append({
107
+ "name": item["metadata"]["name"],
108
+ "namespace": item["metadata"]["namespace"],
109
+ "phase": status.get("phase", "Unknown"),
110
+ "ready": ready_cond.get("status") == "True",
111
+ "infrastructure_ready": infra_ready.get("status") == "True",
112
+ "control_plane_ready": cp_ready.get("status") == "True",
113
+ "control_plane_endpoint": spec.get("controlPlaneEndpoint", {}),
114
+ "infrastructure_ref": spec.get("infrastructureRef", {}),
115
+ "control_plane_ref": spec.get("controlPlaneRef", {}),
116
+ "cluster_network": spec.get("clusterNetwork", {}),
117
+ "paused": spec.get("paused", False),
118
+ "observed_generation": status.get("observedGeneration"),
119
+ "failure_reason": status.get("failureReason"),
120
+ "failure_message": status.get("failureMessage"),
121
+ })
122
+
123
+ # Summary
124
+ ready = sum(1 for c in clusters if c["ready"])
125
+ provisioning = sum(1 for c in clusters if c["phase"] == "Provisioning")
126
+
127
+ return {
128
+ "context": context or "current",
129
+ "total": len(clusters),
130
+ "ready": ready,
131
+ "provisioning": provisioning,
132
+ "clusters": clusters,
133
+ }
134
+
135
+
136
+ def capi_cluster_get(
137
+ name: str,
138
+ namespace: str,
139
+ context: str = ""
140
+ ) -> Dict[str, Any]:
141
+ """Get detailed information about a CAPI cluster.
142
+
143
+ Args:
144
+ name: Name of the cluster
145
+ namespace: Namespace of the cluster
146
+ context: Kubernetes context to use (optional)
147
+
148
+ Returns:
149
+ Detailed cluster information
150
+ """
151
+ if not crd_exists(CLUSTER_CRD, context):
152
+ return {"success": False, "error": "Cluster API is not installed"}
153
+
154
+ args = ["get", "clusters.cluster.x-k8s.io", name, "-n", namespace, "-o", "json"]
155
+ result = _run_kubectl(args, context)
156
+
157
+ if result["success"]:
158
+ try:
159
+ data = json.loads(result["output"])
160
+ return {
161
+ "success": True,
162
+ "context": context or "current",
163
+ "cluster": data,
164
+ }
165
+ except json.JSONDecodeError:
166
+ return {"success": False, "error": "Failed to parse response"}
167
+
168
+ return {"success": False, "error": result.get("error", "Unknown error")}
169
+
170
+
171
+ def capi_machines_list(
172
+ namespace: str = "",
173
+ cluster_name: str = "",
174
+ context: str = "",
175
+ label_selector: str = ""
176
+ ) -> Dict[str, Any]:
177
+ """List Cluster API machines.
178
+
179
+ Args:
180
+ namespace: Filter by namespace (empty for all namespaces)
181
+ cluster_name: Filter by cluster name
182
+ context: Kubernetes context to use (optional)
183
+ label_selector: Label selector to filter machines
184
+
185
+ Returns:
186
+ List of machines with their status
187
+ """
188
+ if not crd_exists(MACHINE_CRD, context):
189
+ return {
190
+ "success": False,
191
+ "error": "Cluster API is not installed (machines.cluster.x-k8s.io CRD not found)"
192
+ }
193
+
194
+ # Build label selector
195
+ selector = label_selector
196
+ if cluster_name:
197
+ cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
198
+ selector = f"{selector},{cluster_label}" if selector else cluster_label
199
+
200
+ machines = []
201
+ for item in _get_resources("machines.cluster.x-k8s.io", namespace, context, selector):
202
+ status = item.get("status", {})
203
+ spec = item.get("spec", {})
204
+ conditions = status.get("conditions", [])
205
+
206
+ ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
207
+ infra_ready = next((c for c in conditions if c.get("type") == "InfrastructureReady"), {})
208
+
209
+ machines.append({
210
+ "name": item["metadata"]["name"],
211
+ "namespace": item["metadata"]["namespace"],
212
+ "cluster": spec.get("clusterName", ""),
213
+ "phase": status.get("phase", "Unknown"),
214
+ "ready": ready_cond.get("status") == "True",
215
+ "infrastructure_ready": infra_ready.get("status") == "True",
216
+ "provider_id": spec.get("providerID", ""),
217
+ "version": spec.get("version", ""),
218
+ "bootstrap_ref": spec.get("bootstrap", {}).get("configRef", {}),
219
+ "infrastructure_ref": spec.get("infrastructureRef", {}),
220
+ "node_ref": status.get("nodeRef", {}),
221
+ "addresses": status.get("addresses", []),
222
+ "failure_reason": status.get("failureReason"),
223
+ "failure_message": status.get("failureMessage"),
224
+ })
225
+
226
+ # Summary
227
+ ready = sum(1 for m in machines if m["ready"])
228
+ running = sum(1 for m in machines if m["phase"] == "Running")
229
+
230
+ return {
231
+ "context": context or "current",
232
+ "total": len(machines),
233
+ "ready": ready,
234
+ "running": running,
235
+ "machines": machines,
236
+ }
237
+
238
+
239
+ def capi_machine_get(
240
+ name: str,
241
+ namespace: str,
242
+ context: str = ""
243
+ ) -> Dict[str, Any]:
244
+ """Get detailed information about a CAPI machine.
245
+
246
+ Args:
247
+ name: Name of the machine
248
+ namespace: Namespace of the machine
249
+ context: Kubernetes context to use (optional)
250
+
251
+ Returns:
252
+ Detailed machine information
253
+ """
254
+ if not crd_exists(MACHINE_CRD, context):
255
+ return {"success": False, "error": "Cluster API is not installed"}
256
+
257
+ args = ["get", "machines.cluster.x-k8s.io", name, "-n", namespace, "-o", "json"]
258
+ result = _run_kubectl(args, context)
259
+
260
+ if result["success"]:
261
+ try:
262
+ data = json.loads(result["output"])
263
+ return {
264
+ "success": True,
265
+ "context": context or "current",
266
+ "machine": data,
267
+ }
268
+ except json.JSONDecodeError:
269
+ return {"success": False, "error": "Failed to parse response"}
270
+
271
+ return {"success": False, "error": result.get("error", "Unknown error")}
272
+
273
+
274
+ def capi_machinedeployments_list(
275
+ namespace: str = "",
276
+ cluster_name: str = "",
277
+ context: str = "",
278
+ label_selector: str = ""
279
+ ) -> Dict[str, Any]:
280
+ """List Cluster API MachineDeployments.
281
+
282
+ Args:
283
+ namespace: Filter by namespace (empty for all namespaces)
284
+ cluster_name: Filter by cluster name
285
+ context: Kubernetes context to use (optional)
286
+ label_selector: Label selector to filter
287
+
288
+ Returns:
289
+ List of MachineDeployments with their status
290
+ """
291
+ if not crd_exists(MACHINEDEPLOYMENT_CRD, context):
292
+ return {
293
+ "success": False,
294
+ "error": "MachineDeployments CRD not found"
295
+ }
296
+
297
+ # Build label selector
298
+ selector = label_selector
299
+ if cluster_name:
300
+ cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
301
+ selector = f"{selector},{cluster_label}" if selector else cluster_label
302
+
303
+ deployments = []
304
+ for item in _get_resources("machinedeployments.cluster.x-k8s.io", namespace, context, selector):
305
+ status = item.get("status", {})
306
+ spec = item.get("spec", {})
307
+ conditions = status.get("conditions", [])
308
+
309
+ ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
310
+ available_cond = next((c for c in conditions if c.get("type") == "Available"), {})
311
+
312
+ deployments.append({
313
+ "name": item["metadata"]["name"],
314
+ "namespace": item["metadata"]["namespace"],
315
+ "cluster": spec.get("clusterName", ""),
316
+ "phase": status.get("phase", "Unknown"),
317
+ "ready": ready_cond.get("status") == "True",
318
+ "available": available_cond.get("status") == "True",
319
+ "replicas": spec.get("replicas", 0),
320
+ "ready_replicas": status.get("readyReplicas", 0),
321
+ "available_replicas": status.get("availableReplicas", 0),
322
+ "updated_replicas": status.get("updatedReplicas", 0),
323
+ "unavailable_replicas": status.get("unavailableReplicas", 0),
324
+ "version": spec.get("template", {}).get("spec", {}).get("version", ""),
325
+ "strategy": spec.get("strategy", {}),
326
+ "observed_generation": status.get("observedGeneration"),
327
+ })
328
+
329
+ return {
330
+ "context": context or "current",
331
+ "total": len(deployments),
332
+ "deployments": deployments,
333
+ }
334
+
335
+
336
+ def capi_machinedeployment_scale(
337
+ name: str,
338
+ namespace: str,
339
+ replicas: int,
340
+ context: str = ""
341
+ ) -> Dict[str, Any]:
342
+ """Scale a MachineDeployment.
343
+
344
+ Args:
345
+ name: Name of the MachineDeployment
346
+ namespace: Namespace of the MachineDeployment
347
+ replicas: Desired number of replicas
348
+ context: Kubernetes context to use (optional)
349
+
350
+ Returns:
351
+ Scale result
352
+ """
353
+ if not crd_exists(MACHINEDEPLOYMENT_CRD, context):
354
+ return {"success": False, "error": "Cluster API is not installed"}
355
+
356
+ if replicas < 0:
357
+ return {"success": False, "error": "Replicas must be >= 0"}
358
+
359
+ args = [
360
+ "scale", "machinedeployments.cluster.x-k8s.io", name,
361
+ "-n", namespace,
362
+ f"--replicas={replicas}"
363
+ ]
364
+ result = _run_kubectl(args, context)
365
+
366
+ if result["success"]:
367
+ return {
368
+ "success": True,
369
+ "context": context or "current",
370
+ "message": f"Scaled MachineDeployment {name} to {replicas} replicas",
371
+ }
372
+
373
+ return {"success": False, "error": result.get("error", "Failed to scale")}
374
+
375
+
376
+ def capi_machinesets_list(
377
+ namespace: str = "",
378
+ cluster_name: str = "",
379
+ context: str = "",
380
+ label_selector: str = ""
381
+ ) -> Dict[str, Any]:
382
+ """List Cluster API MachineSets.
383
+
384
+ Args:
385
+ namespace: Filter by namespace (empty for all namespaces)
386
+ cluster_name: Filter by cluster name
387
+ context: Kubernetes context to use (optional)
388
+ label_selector: Label selector to filter
389
+
390
+ Returns:
391
+ List of MachineSets with their status
392
+ """
393
+ if not crd_exists(MACHINESET_CRD, context):
394
+ return {
395
+ "success": False,
396
+ "error": "MachineSets CRD not found"
397
+ }
398
+
399
+ selector = label_selector
400
+ if cluster_name:
401
+ cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
402
+ selector = f"{selector},{cluster_label}" if selector else cluster_label
403
+
404
+ machinesets = []
405
+ for item in _get_resources("machinesets.cluster.x-k8s.io", namespace, context, selector):
406
+ status = item.get("status", {})
407
+ spec = item.get("spec", {})
408
+
409
+ machinesets.append({
410
+ "name": item["metadata"]["name"],
411
+ "namespace": item["metadata"]["namespace"],
412
+ "cluster": spec.get("clusterName", ""),
413
+ "replicas": spec.get("replicas", 0),
414
+ "ready_replicas": status.get("readyReplicas", 0),
415
+ "available_replicas": status.get("availableReplicas", 0),
416
+ "fully_labeled_replicas": status.get("fullyLabeledReplicas", 0),
417
+ "version": spec.get("template", {}).get("spec", {}).get("version", ""),
418
+ "selector": spec.get("selector", {}),
419
+ "observed_generation": status.get("observedGeneration"),
420
+ "failure_reason": status.get("failureReason"),
421
+ "failure_message": status.get("failureMessage"),
422
+ })
423
+
424
+ return {
425
+ "context": context or "current",
426
+ "total": len(machinesets),
427
+ "machinesets": machinesets,
428
+ }
429
+
430
+
431
+ def capi_machinehealthchecks_list(
432
+ namespace: str = "",
433
+ cluster_name: str = "",
434
+ context: str = "",
435
+ label_selector: str = ""
436
+ ) -> Dict[str, Any]:
437
+ """List Cluster API MachineHealthChecks.
438
+
439
+ Args:
440
+ namespace: Filter by namespace (empty for all namespaces)
441
+ cluster_name: Filter by cluster name
442
+ context: Kubernetes context to use (optional)
443
+ label_selector: Label selector to filter
444
+
445
+ Returns:
446
+ List of MachineHealthChecks with their status
447
+ """
448
+ if not crd_exists(MACHINEHEALTHCHECK_CRD, context):
449
+ return {
450
+ "success": False,
451
+ "error": "MachineHealthChecks CRD not found"
452
+ }
453
+
454
+ selector = label_selector
455
+ if cluster_name:
456
+ cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
457
+ selector = f"{selector},{cluster_label}" if selector else cluster_label
458
+
459
+ healthchecks = []
460
+ for item in _get_resources("machinehealthchecks.cluster.x-k8s.io", namespace, context, selector):
461
+ status = item.get("status", {})
462
+ spec = item.get("spec", {})
463
+ conditions = status.get("conditions", [])
464
+
465
+ remediation_allowed = next((c for c in conditions if c.get("type") == "RemediationAllowed"), {})
466
+
467
+ healthchecks.append({
468
+ "name": item["metadata"]["name"],
469
+ "namespace": item["metadata"]["namespace"],
470
+ "cluster": spec.get("clusterName", ""),
471
+ "expected_machines": status.get("expectedMachines", 0),
472
+ "current_healthy": status.get("currentHealthy", 0),
473
+ "remediation_allowed": remediation_allowed.get("status") == "True",
474
+ "unhealthy_conditions": spec.get("unhealthyConditions", []),
475
+ "max_unhealthy": spec.get("maxUnhealthy"),
476
+ "node_startup_timeout": spec.get("nodeStartupTimeout"),
477
+ "targets": status.get("targets", []),
478
+ })
479
+
480
+ return {
481
+ "context": context or "current",
482
+ "total": len(healthchecks),
483
+ "healthchecks": healthchecks,
484
+ }
485
+
486
+
487
+ def capi_clusterclasses_list(
488
+ namespace: str = "",
489
+ context: str = "",
490
+ label_selector: str = ""
491
+ ) -> Dict[str, Any]:
492
+ """List Cluster API ClusterClasses.
493
+
494
+ Args:
495
+ namespace: Filter by namespace (empty for all namespaces)
496
+ context: Kubernetes context to use (optional)
497
+ label_selector: Label selector to filter
498
+
499
+ Returns:
500
+ List of ClusterClasses
501
+ """
502
+ if not crd_exists(CLUSTERCLASS_CRD, context):
503
+ return {
504
+ "success": False,
505
+ "error": "ClusterClasses CRD not found"
506
+ }
507
+
508
+ classes = []
509
+ for item in _get_resources("clusterclasses.cluster.x-k8s.io", namespace, context, label_selector):
510
+ spec = item.get("spec", {})
511
+ status = item.get("status", {})
512
+ conditions = status.get("conditions", [])
513
+
514
+ ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
515
+ variables_ready = next((c for c in conditions if c.get("type") == "VariablesReady"), {})
516
+
517
+ workers = spec.get("workers", {})
518
+ machine_deployments = workers.get("machineDeployments", [])
519
+ machine_pools = workers.get("machinePools", [])
520
+
521
+ classes.append({
522
+ "name": item["metadata"]["name"],
523
+ "namespace": item["metadata"]["namespace"],
524
+ "ready": ready_cond.get("status") == "True",
525
+ "variables_ready": variables_ready.get("status") == "True",
526
+ "infrastructure_ref": spec.get("infrastructure", {}).get("ref", {}),
527
+ "control_plane_ref": spec.get("controlPlane", {}).get("ref", {}),
528
+ "machinedeployment_classes": len(machine_deployments),
529
+ "machinepool_classes": len(machine_pools),
530
+ "variables_count": len(spec.get("variables", [])),
531
+ "observed_generation": status.get("observedGeneration"),
532
+ })
533
+
534
+ return {
535
+ "context": context or "current",
536
+ "total": len(classes),
537
+ "clusterclasses": classes,
538
+ }
539
+
540
+
541
+ def capi_cluster_kubeconfig(
542
+ name: str,
543
+ namespace: str,
544
+ context: str = ""
545
+ ) -> Dict[str, Any]:
546
+ """Get kubeconfig for a CAPI cluster.
547
+
548
+ Args:
549
+ name: Name of the cluster
550
+ namespace: Namespace of the cluster
551
+ context: Kubernetes context to use (optional)
552
+
553
+ Returns:
554
+ Kubeconfig secret information
555
+ """
556
+ if not crd_exists(CLUSTER_CRD, context):
557
+ return {"success": False, "error": "Cluster API is not installed"}
558
+
559
+ # CAPI stores kubeconfig in a secret named <cluster-name>-kubeconfig
560
+ secret_name = f"{name}-kubeconfig"
561
+ args = ["get", "secret", secret_name, "-n", namespace, "-o", "json"]
562
+ result = _run_kubectl(args, context)
563
+
564
+ if result["success"]:
565
+ try:
566
+ data = json.loads(result["output"])
567
+ # Don't expose actual kubeconfig data, just metadata
568
+ return {
569
+ "success": True,
570
+ "context": context or "current",
571
+ "secret_name": secret_name,
572
+ "namespace": namespace,
573
+ "exists": True,
574
+ "data_keys": list(data.get("data", {}).keys()),
575
+ "note": "Use 'clusterctl get kubeconfig' or 'kubectl get secret -o jsonpath' to retrieve actual kubeconfig",
576
+ }
577
+ except json.JSONDecodeError:
578
+ return {"success": False, "error": "Failed to parse response"}
579
+
580
+ # Check if this is a NotFound error vs other failures
581
+ error_output = result.get("output", "") + result.get("error", "")
582
+ if "NotFound" in error_output or "not found" in error_output.lower():
583
+ return {
584
+ "success": True,
585
+ "context": context or "current",
586
+ "secret_name": secret_name,
587
+ "namespace": namespace,
588
+ "exists": False,
589
+ "note": "Kubeconfig secret not found - cluster may still be provisioning",
590
+ }
591
+
592
+ # For other kubectl failures, return the actual error
593
+ return {
594
+ "success": False,
595
+ "error": error_output.strip() or "Failed to get kubeconfig secret",
596
+ }
597
+
598
+
599
+ def capi_detect(context: str = "") -> Dict[str, Any]:
600
+ """Detect if Cluster API is installed and its components.
601
+
602
+ Args:
603
+ context: Kubernetes context to use (optional)
604
+
605
+ Returns:
606
+ Detection results for Cluster API
607
+ """
608
+ return {
609
+ "context": context or "current",
610
+ "installed": crd_exists(CLUSTER_CRD, context),
611
+ "cli_available": _clusterctl_available(),
612
+ "crds": {
613
+ "clusters": crd_exists(CLUSTER_CRD, context),
614
+ "machines": crd_exists(MACHINE_CRD, context),
615
+ "machinedeployments": crd_exists(MACHINEDEPLOYMENT_CRD, context),
616
+ "machinesets": crd_exists(MACHINESET_CRD, context),
617
+ "machinepools": crd_exists(MACHINEPOOL_CRD, context),
618
+ "machinehealthchecks": crd_exists(MACHINEHEALTHCHECK_CRD, context),
619
+ "clusterclasses": crd_exists(CLUSTERCLASS_CRD, context),
620
+ },
621
+ }
622
+
623
+
624
+ def register_capi_tools(mcp: FastMCP, non_destructive: bool = False):
625
+ """Register Cluster API tools with the MCP server."""
626
+
627
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
628
+ def capi_clusters_list_tool(
629
+ namespace: str = "",
630
+ context: str = "",
631
+ label_selector: str = ""
632
+ ) -> str:
633
+ """List Cluster API managed clusters."""
634
+ return json.dumps(capi_clusters_list(namespace, context, label_selector), indent=2)
635
+
636
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
637
+ def capi_cluster_get_tool(
638
+ name: str,
639
+ namespace: str,
640
+ context: str = ""
641
+ ) -> str:
642
+ """Get detailed information about a CAPI cluster."""
643
+ return json.dumps(capi_cluster_get(name, namespace, context), indent=2)
644
+
645
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
646
+ def capi_machines_list_tool(
647
+ namespace: str = "",
648
+ cluster_name: str = "",
649
+ context: str = "",
650
+ label_selector: str = ""
651
+ ) -> str:
652
+ """List Cluster API machines."""
653
+ return json.dumps(capi_machines_list(namespace, cluster_name, context, label_selector), indent=2)
654
+
655
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
656
+ def capi_machine_get_tool(
657
+ name: str,
658
+ namespace: str,
659
+ context: str = ""
660
+ ) -> str:
661
+ """Get detailed information about a CAPI machine."""
662
+ return json.dumps(capi_machine_get(name, namespace, context), indent=2)
663
+
664
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
665
+ def capi_machinedeployments_list_tool(
666
+ namespace: str = "",
667
+ cluster_name: str = "",
668
+ context: str = "",
669
+ label_selector: str = ""
670
+ ) -> str:
671
+ """List Cluster API MachineDeployments."""
672
+ return json.dumps(capi_machinedeployments_list(namespace, cluster_name, context, label_selector), indent=2)
673
+
674
+ @mcp.tool()
675
+ def capi_machinedeployment_scale_tool(
676
+ name: str,
677
+ namespace: str,
678
+ replicas: int,
679
+ context: str = ""
680
+ ) -> str:
681
+ """Scale a CAPI MachineDeployment."""
682
+ if non_destructive:
683
+ return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
684
+ return json.dumps(capi_machinedeployment_scale(name, namespace, replicas, context), indent=2)
685
+
686
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
687
+ def capi_machinesets_list_tool(
688
+ namespace: str = "",
689
+ cluster_name: str = "",
690
+ context: str = "",
691
+ label_selector: str = ""
692
+ ) -> str:
693
+ """List Cluster API MachineSets."""
694
+ return json.dumps(capi_machinesets_list(namespace, cluster_name, context, label_selector), indent=2)
695
+
696
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
697
+ def capi_machinehealthchecks_list_tool(
698
+ namespace: str = "",
699
+ cluster_name: str = "",
700
+ context: str = "",
701
+ label_selector: str = ""
702
+ ) -> str:
703
+ """List Cluster API MachineHealthChecks."""
704
+ return json.dumps(capi_machinehealthchecks_list(namespace, cluster_name, context, label_selector), indent=2)
705
+
706
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
707
+ def capi_clusterclasses_list_tool(
708
+ namespace: str = "",
709
+ context: str = "",
710
+ label_selector: str = ""
711
+ ) -> str:
712
+ """List Cluster API ClusterClasses."""
713
+ return json.dumps(capi_clusterclasses_list(namespace, context, label_selector), indent=2)
714
+
715
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
716
+ def capi_cluster_kubeconfig_tool(
717
+ name: str,
718
+ namespace: str,
719
+ context: str = ""
720
+ ) -> str:
721
+ """Get kubeconfig secret info for a CAPI cluster."""
722
+ return json.dumps(capi_cluster_kubeconfig(name, namespace, context), indent=2)
723
+
724
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
725
+ def capi_detect_tool(context: str = "") -> str:
726
+ """Detect if Cluster API is installed and its components."""
727
+ return json.dumps(capi_detect(context), indent=2)