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
@@ -7,6 +7,7 @@ All tools support multi-cluster operations via the optional 'context' parameter.
7
7
  import json
8
8
  import logging
9
9
  import os
10
+ import re
10
11
  import subprocess
11
12
  from typing import Any, Dict, List, Optional
12
13
 
@@ -30,6 +31,31 @@ def _get_kubectl_context_args(context: str = "") -> List[str]:
30
31
  return []
31
32
 
32
33
 
34
+ # DNS-1123 subdomain regex for node name validation
35
+ _DNS_1123_PATTERN = re.compile(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')
36
+
37
+
38
+ def _validate_node_name(name: str) -> tuple:
39
+ """Validate that a node name follows DNS-1123 subdomain rules.
40
+
41
+ Args:
42
+ name: Node name to validate
43
+
44
+ Returns:
45
+ Tuple of (is_valid: bool, error_message: Optional[str])
46
+ """
47
+ if not name:
48
+ return False, "Node name cannot be empty"
49
+ if len(name) > 253:
50
+ return False, f"Node name too long: {len(name)} chars (max 253)"
51
+ if not _DNS_1123_PATTERN.match(name):
52
+ return False, (
53
+ f"Invalid node name '{name}': must be a valid DNS-1123 subdomain "
54
+ "(lowercase alphanumeric, '-' or '.', must start/end with alphanumeric)"
55
+ )
56
+ return True, None
57
+
58
+
33
59
  def register_cluster_tools(server, non_destructive: bool):
34
60
  """Register cluster and context management tools."""
35
61
 
@@ -587,3 +613,361 @@ def register_cluster_tools(server, non_destructive: bool):
587
613
  except Exception as e:
588
614
  logger.error(f"Error getting nodes summary: {e}")
589
615
  return {"success": False, "error": str(e)}
616
+
617
+ # ========== Node Kubelet Tools ==========
618
+
619
+ @server.tool(
620
+ annotations=ToolAnnotations(
621
+ title="Get Node Logs",
622
+ readOnlyHint=True,
623
+ ),
624
+ )
625
+ def node_logs_tool(
626
+ name: str,
627
+ query: str = "kubelet",
628
+ tail_lines: int = 100,
629
+ context: str = ""
630
+ ) -> Dict[str, Any]:
631
+ """Get logs from a Kubernetes node via kubelet API proxy.
632
+
633
+ This tool retrieves logs from a node's kubelet service or system log files.
634
+ Common service names: kubelet, kube-proxy, containerd, docker.
635
+ For file paths, use the full path like /var/log/syslog or /var/log/messages.
636
+
637
+ Args:
638
+ name: Node name
639
+ query: Service name (kubelet, kube-proxy) or log file path (/var/log/syslog)
640
+ tail_lines: Number of lines from end (0 = all lines)
641
+ context: Kubernetes context to use (uses current context if not specified)
642
+ """
643
+ try:
644
+ # Validate node name
645
+ is_valid, error_msg = _validate_node_name(name)
646
+ if not is_valid:
647
+ return {"success": False, "error": error_msg}
648
+
649
+ ctx_args = _get_kubectl_context_args(context)
650
+
651
+ # Build the proxy URL path
652
+ log_path = query.lstrip("/") if query.startswith("/var/log") else query
653
+
654
+ # Use kubectl proxy to access kubelet logs
655
+ cmd = ["kubectl"] + ctx_args + [
656
+ "get", "--raw",
657
+ f"/api/v1/nodes/{name}/proxy/logs/{log_path}"
658
+ ]
659
+
660
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
661
+
662
+ if result.returncode != 0:
663
+ error_msg = result.stderr.strip()
664
+ if "not found" in error_msg.lower():
665
+ return {
666
+ "success": False,
667
+ "error": f"Node '{name}' not found or log path '{query}' is invalid",
668
+ "hint": "Try: kubelet, kube-proxy, or /var/log/syslog"
669
+ }
670
+ return {"success": False, "error": error_msg}
671
+
672
+ # Handle empty or None output
673
+ logs = result.stdout or ""
674
+ if not logs.strip():
675
+ lines = []
676
+ total_lines = 0
677
+ else:
678
+ lines = logs.splitlines()
679
+ total_lines = len(lines)
680
+
681
+ # Apply tail_lines if specified
682
+ if tail_lines > 0 and len(lines) > tail_lines:
683
+ lines = lines[-tail_lines:]
684
+ truncated = True
685
+ else:
686
+ truncated = False
687
+
688
+ return {
689
+ "success": True,
690
+ "context": context or "current",
691
+ "node": name,
692
+ "query": query,
693
+ "tailLines": tail_lines,
694
+ "truncated": truncated,
695
+ "totalLines": total_lines,
696
+ "returnedLines": len(lines),
697
+ "logs": "\n".join(lines)
698
+ }
699
+ except subprocess.TimeoutExpired:
700
+ return {"success": False, "error": "Log retrieval timed out after 60 seconds"}
701
+ except Exception as e:
702
+ logger.error(f"Error getting node logs: {e}")
703
+ return {"success": False, "error": str(e)}
704
+
705
+ @server.tool(
706
+ annotations=ToolAnnotations(
707
+ title="Get Node Stats Summary",
708
+ readOnlyHint=True,
709
+ ),
710
+ )
711
+ def node_stats_summary_tool(
712
+ name: str,
713
+ context: str = ""
714
+ ) -> Dict[str, Any]:
715
+ """Get resource usage statistics from node via kubelet Summary API.
716
+
717
+ Returns CPU, memory, filesystem, and network usage at node, pod, and
718
+ container levels. On systems with cgroup v2 and kernel 4.20+, may include
719
+ PSI (Pressure Stall Information) metrics.
720
+
721
+ This provides more detailed metrics than 'kubectl top nodes' including:
722
+ - Node-level: CPU, memory, filesystem, network, runtime stats
723
+ - Pod-level: CPU, memory, network, volume stats for each pod
724
+ - Container-level: CPU, memory, rootfs, logs usage for each container
725
+
726
+ Args:
727
+ name: Node name
728
+ context: Kubernetes context to use (uses current context if not specified)
729
+ """
730
+ try:
731
+ # Validate node name
732
+ is_valid, error_msg = _validate_node_name(name)
733
+ if not is_valid:
734
+ return {"success": False, "error": error_msg}
735
+
736
+ ctx_args = _get_kubectl_context_args(context)
737
+
738
+ cmd = ["kubectl"] + ctx_args + [
739
+ "get", "--raw",
740
+ f"/api/v1/nodes/{name}/proxy/stats/summary"
741
+ ]
742
+
743
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
744
+
745
+ if result.returncode != 0:
746
+ error_msg = result.stderr.strip()
747
+ if "not found" in error_msg.lower():
748
+ return {"success": False, "error": f"Node '{name}' not found"}
749
+ return {"success": False, "error": error_msg}
750
+
751
+ stats = json.loads(result.stdout)
752
+ node_stats = stats.get("node", {})
753
+ pods_stats = stats.get("pods", [])
754
+
755
+ formatted_node = {
756
+ "nodeName": node_stats.get("nodeName"),
757
+ "startTime": node_stats.get("startTime"),
758
+ "cpu": _format_cpu_stats(node_stats.get("cpu", {})),
759
+ "memory": _format_memory_stats(node_stats.get("memory", {})),
760
+ "network": _format_network_stats(node_stats.get("network", {})),
761
+ "fs": _format_fs_stats(node_stats.get("fs", {})),
762
+ "runtime": _format_runtime_stats(node_stats.get("runtime", {})),
763
+ "rlimit": node_stats.get("rlimit", {}),
764
+ }
765
+
766
+ # Truncation limits
767
+ pod_limit = 50
768
+ container_limit = 5
769
+
770
+ formatted_pods = []
771
+ for pod in pods_stats[:pod_limit]:
772
+ containers = pod.get("containers", [])
773
+ pod_summary = {
774
+ "podRef": pod.get("podRef", {}),
775
+ "startTime": pod.get("startTime"),
776
+ "cpu": _format_cpu_stats(pod.get("cpu", {})),
777
+ "memory": _format_memory_stats(pod.get("memory", {})),
778
+ "network": _format_network_stats(pod.get("network", {})),
779
+ "containers": [],
780
+ "containersTruncated": len(containers) > container_limit
781
+ }
782
+
783
+ for container in containers[:container_limit]:
784
+ pod_summary["containers"].append({
785
+ "name": container.get("name"),
786
+ "cpu": _format_cpu_stats(container.get("cpu", {})),
787
+ "memory": _format_memory_stats(container.get("memory", {})),
788
+ "rootfs": _format_fs_stats(container.get("rootfs", {})),
789
+ })
790
+
791
+ formatted_pods.append(pod_summary)
792
+
793
+ return {
794
+ "success": True,
795
+ "context": context or "current",
796
+ "nodeName": name,
797
+ "nodeStats": formatted_node,
798
+ "podCount": len(pods_stats),
799
+ "pods": formatted_pods,
800
+ "podLimit": pod_limit,
801
+ "containerLimit": container_limit,
802
+ "podsTruncated": len(pods_stats) > pod_limit,
803
+ "rawStatsAvailable": True
804
+ }
805
+ except json.JSONDecodeError as e:
806
+ return {"success": False, "error": f"Failed to parse stats: {e}"}
807
+ except subprocess.TimeoutExpired:
808
+ return {"success": False, "error": "Stats retrieval timed out after 120 seconds"}
809
+ except Exception as e:
810
+ logger.error(f"Error getting node stats summary: {e}")
811
+ return {"success": False, "error": str(e)}
812
+
813
+ @server.tool(
814
+ annotations=ToolAnnotations(
815
+ title="Get Node Top",
816
+ readOnlyHint=True,
817
+ ),
818
+ )
819
+ def node_top_tool(
820
+ name: str = "",
821
+ label_selector: str = "",
822
+ context: str = ""
823
+ ) -> Dict[str, Any]:
824
+ """Get resource consumption (CPU, memory) for nodes from Metrics Server.
825
+
826
+ Similar to 'kubectl top nodes' command. Requires metrics-server to be
827
+ installed in the cluster.
828
+
829
+ Args:
830
+ name: Specific node name (optional, all nodes if empty)
831
+ label_selector: Label selector to filter nodes (e.g., 'node-role.kubernetes.io/control-plane')
832
+ context: Kubernetes context to use (uses current context if not specified)
833
+ """
834
+ try:
835
+ ctx_args = _get_kubectl_context_args(context)
836
+ cmd = ["kubectl"] + ctx_args + ["top", "nodes", "--no-headers"]
837
+
838
+ if label_selector:
839
+ cmd.extend(["-l", label_selector])
840
+
841
+ if name:
842
+ cmd.append(name)
843
+
844
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
845
+
846
+ if result.returncode != 0:
847
+ error_msg = result.stderr.strip()
848
+ if "metrics" in error_msg.lower() or "not available" in error_msg.lower():
849
+ return {
850
+ "success": False,
851
+ "error": "Metrics server not available or not ready",
852
+ "hint": "Install metrics-server: kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml"
853
+ }
854
+ if "not found" in error_msg.lower() and name:
855
+ return {"success": False, "error": f"Node '{name}' not found"}
856
+ return {"success": False, "error": error_msg}
857
+
858
+ metrics = []
859
+ for line in result.stdout.strip().split("\n"):
860
+ if not line.strip():
861
+ continue
862
+ parts = line.split()
863
+ if len(parts) >= 5:
864
+ node_metric = {
865
+ "node": parts[0],
866
+ "cpuCores": parts[1],
867
+ "cpuPercent": parts[2].rstrip("%"),
868
+ "memoryBytes": parts[3],
869
+ "memoryPercent": parts[4].rstrip("%"),
870
+ }
871
+ metrics.append(node_metric)
872
+
873
+ # Use separate counters for valid samples to avoid skewing average
874
+ total_cpu_percent = 0.0
875
+ total_memory_percent = 0.0
876
+ cpu_samples = 0
877
+ memory_samples = 0
878
+ for m in metrics:
879
+ try:
880
+ total_cpu_percent += float(m["cpuPercent"])
881
+ cpu_samples += 1
882
+ except (ValueError, TypeError):
883
+ pass
884
+ try:
885
+ total_memory_percent += float(m["memoryPercent"])
886
+ memory_samples += 1
887
+ except (ValueError, TypeError):
888
+ pass
889
+
890
+ return {
891
+ "success": True,
892
+ "context": context or "current",
893
+ "filter": {
894
+ "name": name or None,
895
+ "labelSelector": label_selector or None
896
+ },
897
+ "nodeCount": len(metrics),
898
+ "nodes": metrics,
899
+ "clusterAverage": {
900
+ "cpuPercent": round(total_cpu_percent / cpu_samples, 1) if cpu_samples else None,
901
+ "memoryPercent": round(total_memory_percent / memory_samples, 1) if memory_samples else None
902
+ } if cpu_samples > 1 or memory_samples > 1 else None
903
+ }
904
+ except subprocess.TimeoutExpired:
905
+ return {"success": False, "error": "Metrics retrieval timed out after 60 seconds"}
906
+ except Exception as e:
907
+ logger.error(f"Error getting node top metrics: {e}")
908
+ return {"success": False, "error": str(e)}
909
+
910
+
911
+ # Helper functions for formatting kubelet stats
912
+
913
+ def _format_cpu_stats(cpu: Dict) -> Dict[str, Any]:
914
+ """Format CPU statistics from kubelet stats."""
915
+ if not cpu:
916
+ return {}
917
+ return {
918
+ "time": cpu.get("time"),
919
+ "usageNanoCores": cpu.get("usageNanoCores"),
920
+ "usageCoreNanoSeconds": cpu.get("usageCoreNanoSeconds"),
921
+ }
922
+
923
+
924
+ def _format_memory_stats(memory: Dict) -> Dict[str, Any]:
925
+ """Format memory statistics from kubelet stats."""
926
+ if not memory:
927
+ return {}
928
+ return {
929
+ "time": memory.get("time"),
930
+ "availableBytes": memory.get("availableBytes"),
931
+ "usageBytes": memory.get("usageBytes"),
932
+ "workingSetBytes": memory.get("workingSetBytes"),
933
+ "rssBytes": memory.get("rssBytes"),
934
+ "pageFaults": memory.get("pageFaults"),
935
+ "majorPageFaults": memory.get("majorPageFaults"),
936
+ }
937
+
938
+
939
+ def _format_network_stats(network: Dict) -> Dict[str, Any]:
940
+ """Format network statistics from kubelet stats."""
941
+ if not network:
942
+ return {}
943
+ return {
944
+ "time": network.get("time"),
945
+ "rxBytes": network.get("rxBytes"),
946
+ "rxErrors": network.get("rxErrors"),
947
+ "txBytes": network.get("txBytes"),
948
+ "txErrors": network.get("txErrors"),
949
+ }
950
+
951
+
952
+ def _format_fs_stats(fs: Dict) -> Dict[str, Any]:
953
+ """Format filesystem statistics from kubelet stats."""
954
+ if not fs:
955
+ return {}
956
+ return {
957
+ "time": fs.get("time"),
958
+ "availableBytes": fs.get("availableBytes"),
959
+ "capacityBytes": fs.get("capacityBytes"),
960
+ "usedBytes": fs.get("usedBytes"),
961
+ "inodesFree": fs.get("inodesFree"),
962
+ "inodes": fs.get("inodes"),
963
+ "inodesUsed": fs.get("inodesUsed"),
964
+ }
965
+
966
+
967
+ def _format_runtime_stats(runtime: Dict) -> Dict[str, Any]:
968
+ """Format runtime (container runtime) statistics from kubelet stats."""
969
+ if not runtime:
970
+ return {}
971
+ return {
972
+ "imageFs": _format_fs_stats(runtime.get("imageFs", {})),
973
+ }