Skip to content

Commit f8170f4

Browse files
Support for nodes_top similar to pods_top
Signed-off-by: Neeraj Krishna Gopalakrishna <[email protected]>
1 parent e526a20 commit f8170f4

File tree

9 files changed

+524
-0
lines changed

9 files changed

+524
-0
lines changed

pkg/kubernetes/accesscontrol_clientset.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface
3939
return a.discoveryClient
4040
}
4141

42+
func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) {
43+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
44+
if !isAllowed(a.staticConfig, gvk) {
45+
return nil, isNotAllowedError(gvk)
46+
}
47+
return a.delegate.CoreV1().Nodes(), nil
48+
}
49+
4250
func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) {
4351
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
4452
if !isAllowed(a.staticConfig, gvk) {
@@ -129,6 +137,29 @@ func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, n
129137
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
130138
}
131139

140+
func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) {
141+
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"}
142+
if !isAllowed(a.staticConfig, gvk) {
143+
return nil, isNotAllowedError(gvk)
144+
}
145+
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
146+
var err error
147+
if name != "" {
148+
m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{})
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err)
151+
}
152+
versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m}
153+
} else {
154+
versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to list node metrics: %w", err)
157+
}
158+
}
159+
convertedMetrics := &metrics.NodeMetricsList{}
160+
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil)
161+
}
162+
132163
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
133164
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
134165
if !isAllowed(a.staticConfig, gvk) {

pkg/kubernetes/nodes.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package kubernetes
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"k8s.io/metrics/pkg/apis/metrics"
10+
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
611
)
712

813
func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, tailLines int64) (string, error) {
@@ -58,3 +63,16 @@ func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string
5863

5964
return string(rawData), nil
6065
}
66+
67+
type NodesTopOptions struct {
68+
metav1.ListOptions
69+
Name string
70+
}
71+
72+
func (k *Kubernetes) NodesTop(ctx context.Context, options NodesTopOptions) (*metrics.NodeMetricsList, error) {
73+
// TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
74+
if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
75+
return nil, errors.New("metrics API is not available")
76+
}
77+
return k.manager.accessControlClientSet.NodesMetricses(ctx, options.Name, options.ListOptions)
78+
}

pkg/mcp/nodes_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,263 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() {
331331
})
332332
}
333333

334+
func (s *NodesSuite) TestNodesTop() {
335+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
336+
w.Header().Set("Content-Type", "application/json")
337+
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
338+
if req.URL.Path == "/api" {
339+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
340+
return
341+
}
342+
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
343+
if req.URL.Path == "/apis" {
344+
_, _ = 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"}}]}`))
345+
return
346+
}
347+
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
348+
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
349+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"nodes","singularName":"","namespaced":false,"kind":"NodeMetrics","verbs":["get","list"]}]}`))
350+
return
351+
}
352+
// List Nodes
353+
if req.URL.Path == "/api/v1/nodes" {
354+
_, _ = w.Write([]byte(`{
355+
"apiVersion": "v1",
356+
"kind": "NodeList",
357+
"items": [
358+
{
359+
"metadata": {
360+
"name": "node-1",
361+
"labels": {
362+
"node-role.kubernetes.io/worker": ""
363+
}
364+
},
365+
"status": {
366+
"allocatable": {
367+
"cpu": "4",
368+
"memory": "16Gi"
369+
},
370+
"nodeInfo": {
371+
"swap": {
372+
"capacity": 0
373+
}
374+
}
375+
}
376+
},
377+
{
378+
"metadata": {
379+
"name": "node-2",
380+
"labels": {
381+
"node-role.kubernetes.io/worker": ""
382+
}
383+
},
384+
"status": {
385+
"allocatable": {
386+
"cpu": "4",
387+
"memory": "16Gi"
388+
},
389+
"nodeInfo": {
390+
"swap": {
391+
"capacity": 0
392+
}
393+
}
394+
}
395+
}
396+
]
397+
}`))
398+
return
399+
}
400+
// Get NodeMetrics
401+
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" {
402+
_, _ = w.Write([]byte(`{
403+
"apiVersion": "metrics.k8s.io/v1beta1",
404+
"kind": "NodeMetricsList",
405+
"items": [
406+
{
407+
"metadata": {
408+
"name": "node-1"
409+
},
410+
"timestamp": "2025-10-29T09:00:00Z",
411+
"window": "30s",
412+
"usage": {
413+
"cpu": "500m",
414+
"memory": "2Gi"
415+
}
416+
},
417+
{
418+
"metadata": {
419+
"name": "node-2"
420+
},
421+
"timestamp": "2025-10-29T09:00:00Z",
422+
"window": "30s",
423+
"usage": {
424+
"cpu": "1000m",
425+
"memory": "4Gi"
426+
}
427+
}
428+
]
429+
}`))
430+
return
431+
}
432+
// Get specific NodeMetrics
433+
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes/node-1" {
434+
_, _ = w.Write([]byte(`{
435+
"apiVersion": "metrics.k8s.io/v1beta1",
436+
"kind": "NodeMetrics",
437+
"metadata": {
438+
"name": "node-1"
439+
},
440+
"timestamp": "2025-10-29T09:00:00Z",
441+
"window": "30s",
442+
"usage": {
443+
"cpu": "500m",
444+
"memory": "2Gi"
445+
}
446+
}`))
447+
return
448+
}
449+
w.WriteHeader(http.StatusNotFound)
450+
}))
451+
s.InitMcpClient()
452+
453+
s.Run("nodes_top() - all nodes", func() {
454+
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
455+
s.Require().NotNil(toolResult, "toolResult should not be nil")
456+
s.Run("no error", func() {
457+
s.Falsef(toolResult.IsError, "call tool should succeed")
458+
s.Nilf(err, "call tool should not return error object")
459+
})
460+
s.Run("returns metrics for all nodes", func() {
461+
content := toolResult.Content[0].(mcp.TextContent).Text
462+
s.Contains(content, "node-1", "expected metrics to contain node-1")
463+
s.Contains(content, "node-2", "expected metrics to contain node-2")
464+
s.Contains(content, "CPU(cores)", "expected header with CPU column")
465+
s.Contains(content, "MEMORY(bytes)", "expected header with MEMORY column")
466+
})
467+
})
468+
469+
s.Run("nodes_top(name=node-1) - specific node", func() {
470+
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
471+
"name": "node-1",
472+
})
473+
s.Require().NotNil(toolResult, "toolResult should not be nil")
474+
s.Run("no error", func() {
475+
s.Falsef(toolResult.IsError, "call tool should succeed")
476+
s.Nilf(err, "call tool should not return error object")
477+
})
478+
s.Run("returns metrics for specific node", func() {
479+
content := toolResult.Content[0].(mcp.TextContent).Text
480+
s.Contains(content, "node-1", "expected metrics to contain node-1")
481+
s.Contains(content, "500m", "expected CPU usage of 500m")
482+
s.Contains(content, "2048Mi", "expected memory usage of 2048Mi")
483+
})
484+
})
485+
486+
s.Run("nodes_top(label_selector=node-role.kubernetes.io/worker=)", func() {
487+
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
488+
"label_selector": "node-role.kubernetes.io/worker=",
489+
})
490+
s.Require().NotNil(toolResult, "toolResult should not be nil")
491+
s.Run("no error", func() {
492+
s.Falsef(toolResult.IsError, "call tool should succeed")
493+
s.Nilf(err, "call tool should not return error object")
494+
})
495+
s.Run("returns metrics for filtered nodes", func() {
496+
content := toolResult.Content[0].(mcp.TextContent).Text
497+
s.Contains(content, "node-1", "expected metrics to contain node-1")
498+
s.Contains(content, "node-2", "expected metrics to contain node-2")
499+
})
500+
})
501+
}
502+
503+
func (s *NodesSuite) TestNodesTopMetricsUnavailable() {
504+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
505+
// List Nodes
506+
if req.URL.Path == "/api/v1/nodes" {
507+
w.Header().Set("Content-Type", "application/json")
508+
w.WriteHeader(http.StatusOK)
509+
_, _ = w.Write([]byte(`{
510+
"apiVersion": "v1",
511+
"kind": "NodeList",
512+
"items": [
513+
{
514+
"metadata": {
515+
"name": "node-1"
516+
},
517+
"status": {
518+
"allocatable": {
519+
"cpu": "4",
520+
"memory": "16Gi"
521+
}
522+
}
523+
}
524+
]
525+
}`))
526+
return
527+
}
528+
// Metrics server not available
529+
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" {
530+
w.WriteHeader(http.StatusNotFound)
531+
return
532+
}
533+
w.WriteHeader(http.StatusNotFound)
534+
}))
535+
s.InitMcpClient()
536+
537+
s.Run("nodes_top() - metrics unavailable", func() {
538+
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
539+
s.Require().NotNil(toolResult, "toolResult should not be nil")
540+
s.Run("has error", func() {
541+
s.Truef(toolResult.IsError, "call tool should fail when metrics unavailable")
542+
s.Nilf(err, "call tool should not return error object")
543+
})
544+
s.Run("describes metrics unavailable", func() {
545+
content := toolResult.Content[0].(mcp.TextContent).Text
546+
s.Contains(content, "failed to get nodes top", "expected error message about failing to get nodes top")
547+
})
548+
})
549+
}
550+
551+
func (s *NodesSuite) TestNodesTopDenied() {
552+
s.Require().NoError(toml.Unmarshal([]byte(`
553+
denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
554+
`), s.Cfg), "Expected to parse denied resources config")
555+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
556+
w.Header().Set("Content-Type", "application/json")
557+
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
558+
if req.URL.Path == "/api" {
559+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
560+
return
561+
}
562+
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
563+
if req.URL.Path == "/apis" {
564+
_, _ = 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"}}]}`))
565+
return
566+
}
567+
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
568+
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
569+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"nodes","singularName":"","namespaced":false,"kind":"NodeMetrics","verbs":["get","list"]}]}`))
570+
return
571+
}
572+
w.WriteHeader(http.StatusNotFound)
573+
}))
574+
s.InitMcpClient()
575+
576+
s.Run("nodes_top (denied)", func() {
577+
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
578+
s.Require().NotNil(toolResult, "toolResult should not be nil")
579+
s.Run("has error", func() {
580+
s.Truef(toolResult.IsError, "call tool should fail")
581+
s.Nilf(err, "call tool should not return error object")
582+
})
583+
s.Run("describes denial", func() {
584+
expectedMessage := "failed to get nodes top: resource not allowed: metrics.k8s.io/v1beta1, Kind=NodeMetrics"
585+
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
586+
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
587+
})
588+
})
589+
}
590+
334591
func TestNodes(t *testing.T) {
335592
suite.Run(t, new(NodesSuite))
336593
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,31 @@
9090
},
9191
"name": "nodes_stats_summary"
9292
},
93+
{
94+
"annotations": {
95+
"title": "Nodes: Top",
96+
"readOnlyHint": true,
97+
"destructiveHint": false,
98+
"idempotentHint": true,
99+
"openWorldHint": true
100+
},
101+
"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",
102+
"inputSchema": {
103+
"type": "object",
104+
"properties": {
105+
"name": {
106+
"description": "Name of the Node to get the resource consumption from (Optional, all Nodes if not provided)",
107+
"type": "string"
108+
},
109+
"label_selector": {
110+
"description": "Kubernetes label selector (e.g. 'node-role.kubernetes.io/worker=') to filter nodes by label (Optional, only applicable when name is not provided)",
111+
"pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
112+
"type": "string"
113+
}
114+
}
115+
},
116+
"name": "nodes_top"
117+
},
93118
{
94119
"annotations": {
95120
"title": "Pods: Delete",

0 commit comments

Comments
 (0)