diff --git a/README.md b/README.md index 4c916af9..ee592bd5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/kubernetes/accesscontrol_clientset.go b/pkg/kubernetes/accesscontrol_clientset.go index c36a9b7f..a6c3fccd 100644 --- a/pkg/kubernetes/accesscontrol_clientset.go +++ b/pkg/kubernetes/accesscontrol_clientset.go @@ -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) { @@ -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) { diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go index e242eac8..a4321a9f 100644 --- a/pkg/kubernetes/nodes.go +++ b/pkg/kubernetes/nodes.go @@ -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) { @@ -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) +} diff --git a/pkg/mcp/nodes_top_test.go b/pkg/mcp/nodes_top_test.go new file mode 100644 index 00000000..23ae9945 --- /dev/null +++ b/pkg/mcp/nodes_top_test.go @@ -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)) +} diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index 56b998da..e8753758 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -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", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 1551b4c2..08181078 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -268,6 +268,39 @@ }, "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": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "enum": [ + "extra-cluster", + "fake-context" + ], + "type": "string" + }, + "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", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index 6e85e401..74a48d56 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -240,6 +240,35 @@ }, "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": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "type": "string" + }, + "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", diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index fb24138e..041c8671 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -196,6 +196,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", diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 5a4b5112..2f314aec 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -196,6 +196,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", diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index fc06a2d9..04798d0d 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -1,13 +1,19 @@ package core import ( + "bytes" "errors" "fmt" "github.com/google/jsonschema-go/jsonschema" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kubectl/pkg/metricsutil" "k8s.io/utils/ptr" "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" ) func initNodes() []api.ServerTool { @@ -64,6 +70,31 @@ func initNodes() []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: nodesStatsSummary}, + {Tool: api.Tool{ + Name: "nodes_top", + 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: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the Node to get the resource consumption from (Optional, all Nodes if not provided)", + }, + "label_selector": { + Type: "string", + 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]", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Nodes: Top", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: nodesTop}, } } @@ -110,3 +141,53 @@ func nodesStatsSummary(params api.ToolHandlerParams) (*api.ToolCallResult, error } return api.NewToolCallResult(ret, nil), nil } + +func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + nodesTopOptions := kubernetes.NodesTopOptions{} + if v, ok := params.GetArguments()["name"].(string); ok { + nodesTopOptions.Name = v + } + if v, ok := params.GetArguments()["label_selector"].(string); ok { + nodesTopOptions.LabelSelector = v + } + + nodeMetrics, err := params.NodesTop(params, nodesTopOptions) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get nodes top: %v", err)), nil + } + + // Get the list of nodes to extract their allocatable resources + nodes, err := params.AccessControlClientset().Nodes() + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get nodes client: %v", err)), nil + } + + nodeList, err := nodes.List(params, metav1.ListOptions{ + LabelSelector: nodesTopOptions.LabelSelector, + }) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list nodes: %v", err)), nil + } + + // Build availableResources map + availableResources := make(map[string]v1.ResourceList) + for _, n := range nodeList.Items { + availableResources[n.Name] = n.Status.Allocatable + + // Handle swap if available + if n.Status.NodeInfo.Swap != nil && n.Status.NodeInfo.Swap.Capacity != nil { + swapCapacity := *n.Status.NodeInfo.Swap.Capacity + availableResources[n.Name]["swap"] = *resource.NewQuantity(swapCapacity, resource.BinarySI) + } + } + + // Print the metrics + buf := new(bytes.Buffer) + printer := metricsutil.NewTopCmdPrinter(buf, true) + err = printer.PrintNodeMetrics(nodeMetrics.Items, availableResources, false, "") + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to print node metrics: %v", err)), nil + } + + return api.NewToolCallResult(buf.String(), nil), nil +}