Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ In case multi-cluster support is enabled (default) and you have access to multip
- **nodes_stats_summary** - Get detailed resource usage statistics from a Kubernetes node via the kubelet's Summary API. Provides comprehensive metrics including CPU, memory, filesystem, and network usage at the node, pod, and container levels. On systems with cgroup v2 and kernel 4.20+, also includes PSI (Pressure Stall Information) metrics that show resource pressure for CPU, memory, and I/O. See https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ for details on PSI metrics
- `name` (`string`) **(required)** - Name of the node to get stats from

- **nodes_top** - List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Nodes or all nodes in the cluster
- `label_selector` (`string`) - Kubernetes label selector (e.g. 'node-role.kubernetes.io/worker=') to filter nodes by label (Optional, only applicable when name is not provided)
- `name` (`string`) - Name of the Node to get the resource consumption from (Optional, all Nodes if not provided)

- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label

Expand Down
31 changes: 31 additions & 0 deletions pkg/kubernetes/accesscontrol_clientset.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface
return a.discoveryClient
}

func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
return a.delegate.CoreV1().Nodes(), nil
}

func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
if !isAllowed(a.staticConfig, gvk) {
Expand All @@ -55,6 +63,29 @@ func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*r
AbsPath(url...), nil
}

func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) {
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
var err error
if name != "" {
m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err)
}
versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m}
} else {
versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions)
if err != nil {
return nil, fmt.Errorf("failed to list node metrics: %w", err)
}
}
convertedMetrics := &metrics.NodeMetricsList{}
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil)
}

func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
if !isAllowed(a.staticConfig, gvk) {
Expand Down
18 changes: 18 additions & 0 deletions pkg/kubernetes/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ package kubernetes

import (
"context"
"errors"
"fmt"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
)

func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, tailLines int64) (string, error) {
Expand Down Expand Up @@ -59,3 +64,16 @@ func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string

return string(rawData), nil
}

type NodesTopOptions struct {
metav1.ListOptions
Name string
}

func (k *Kubernetes) NodesTop(ctx context.Context, options NodesTopOptions) (*metrics.NodeMetricsList, error) {
// TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
return nil, errors.New("metrics API is not available")
}
return k.manager.accessControlClientSet.NodesMetricses(ctx, options.Name, options.ListOptions)
}
248 changes: 248 additions & 0 deletions pkg/mcp/nodes_top_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package mcp

import (
"net/http"
"testing"

"github.com/BurntSushi/toml"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
)

type NodesTopSuite struct {
BaseMcpSuite
mockServer *test.MockServer
}

func (s *NodesTopSuite) SetupTest() {
s.BaseMcpSuite.SetupTest()
s.mockServer = test.NewMockServer()
s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
if req.URL.Path == "/api" {
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
return
}
}))
}

func (s *NodesTopSuite) TearDownTest() {
s.BaseMcpSuite.TearDownTest()
if s.mockServer != nil {
s.mockServer.Close()
}
}

func (s *NodesTopSuite) WithMetricsServer() {
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
if req.URL.Path == "/apis" {
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"metrics.k8s.io","versions":[{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}],"preferredVersion":{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}}]}`))
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"nodes","singularName":"","namespaced":false,"kind":"NodeMetrics","verbs":["get","list"]}]}`))
return
}
}))
}

func (s *NodesTopSuite) TestNodesTop() {
s.WithMetricsServer()
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// List Nodes
if req.URL.Path == "/api/v1/nodes" {
_, _ = w.Write([]byte(`{
"apiVersion": "v1",
"kind": "NodeList",
"items": [
{
"metadata": {
"name": "node-1",
"labels": {
"node-role.kubernetes.io/worker": ""
}
},
"status": {
"allocatable": {
"cpu": "4",
"memory": "16Gi"
},
"nodeInfo": {
"swap": {
"capacity": 0
}
}
}
},
{
"metadata": {
"name": "node-2",
"labels": {
"node-role.kubernetes.io/worker": ""
}
},
"status": {
"allocatable": {
"cpu": "4",
"memory": "16Gi"
},
"nodeInfo": {
"swap": {
"capacity": 0
}
}
}
}
]
}`))
return
}
// Get NodeMetrics
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" {
_, _ = w.Write([]byte(`{
"apiVersion": "metrics.k8s.io/v1beta1",
"kind": "NodeMetricsList",
"items": [
{
"metadata": {
"name": "node-1"
},
"timestamp": "2025-10-29T09:00:00Z",
"window": "30s",
"usage": {
"cpu": "500m",
"memory": "2Gi"
}
},
{
"metadata": {
"name": "node-2"
},
"timestamp": "2025-10-29T09:00:00Z",
"window": "30s",
"usage": {
"cpu": "1000m",
"memory": "4Gi"
}
}
]
}`))
return
}
// Get specific NodeMetrics
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes/node-1" {
_, _ = w.Write([]byte(`{
"apiVersion": "metrics.k8s.io/v1beta1",
"kind": "NodeMetrics",
"metadata": {
"name": "node-1"
},
"timestamp": "2025-10-29T09:00:00Z",
"window": "30s",
"usage": {
"cpu": "500m",
"memory": "2Gi"
}
}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
s.InitMcpClient()

s.Run("nodes_top() - all nodes", func() {
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
s.Falsef(toolResult.IsError, "call tool should succeed")
s.Nilf(err, "call tool should not return error object")
})
s.Run("returns metrics for all nodes", func() {
content := toolResult.Content[0].(mcp.TextContent).Text
s.Contains(content, "node-1", "expected metrics to contain node-1")
s.Contains(content, "node-2", "expected metrics to contain node-2")
s.Contains(content, "CPU(cores)", "expected header with CPU column")
s.Contains(content, "MEMORY(bytes)", "expected header with MEMORY column")
})
})

s.Run("nodes_top(name=node-1) - specific node", func() {
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
"name": "node-1",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
s.Falsef(toolResult.IsError, "call tool should succeed")
s.Nilf(err, "call tool should not return error object")
})
s.Run("returns metrics for specific node", func() {
content := toolResult.Content[0].(mcp.TextContent).Text
s.Contains(content, "node-1", "expected metrics to contain node-1")
s.Contains(content, "500m", "expected CPU usage of 500m")
s.Contains(content, "2048Mi", "expected memory usage of 2048Mi")
})
})

s.Run("nodes_top(label_selector=node-role.kubernetes.io/worker=)", func() {
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
"label_selector": "node-role.kubernetes.io/worker=",
})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("no error", func() {
s.Falsef(toolResult.IsError, "call tool should succeed")
s.Nilf(err, "call tool should not return error object")
})
s.Run("returns metrics for filtered nodes", func() {
content := toolResult.Content[0].(mcp.TextContent).Text
s.Contains(content, "node-1", "expected metrics to contain node-1")
s.Contains(content, "node-2", "expected metrics to contain node-2")
})
})
}

func (s *NodesTopSuite) TestNodesTopMetricsUnavailable() {
s.InitMcpClient()

s.Run("nodes_top() - metrics unavailable", func() {
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail when metrics unavailable")
s.Nilf(err, "call tool should not return error object")
})
s.Run("describes metrics unavailable", func() {
content := toolResult.Content[0].(mcp.TextContent).Text
s.Contains(content, "failed to get nodes top", "expected error message about failing to get nodes top")
})
})
}

func (s *NodesTopSuite) TestNodesTopDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
`), s.Cfg), "Expected to parse denied resources config")
s.WithMetricsServer()
s.InitMcpClient()
s.Run("nodes_top (denied)", func() {
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
s.Require().NotNil(toolResult, "toolResult should not be nil")
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
s.Run("describes denial", func() {
expectedMessage := "failed to get nodes top: resource not allowed: metrics.k8s.io/v1beta1, Kind=NodeMetrics"
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
})
})
}

func TestNodesTop(t *testing.T) {
suite.Run(t, new(NodesTopSuite))
}
25 changes: 25 additions & 0 deletions pkg/mcp/testdata/toolsets-core-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,31 @@
},
"name": "nodes_stats_summary"
},
{
"annotations": {
"title": "Nodes: Top",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Nodes or all nodes in the cluster",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"description": "Name of the Node to get the resource consumption from (Optional, all Nodes if not provided)",
"type": "string"
},
"label_selector": {
"description": "Kubernetes label selector (e.g. 'node-role.kubernetes.io/worker=') to filter nodes by label (Optional, only applicable when name is not provided)",
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
"type": "string"
}
}
},
"name": "nodes_top"
},
{
"annotations": {
"title": "Pods: Delete",
Expand Down
Loading
Loading