diff --git a/pkg/kubernetes/accesscontrol_clientset.go b/pkg/kubernetes/accesscontrol_clientset.go index 0ce64c49..87c5baec 100644 --- a/pkg/kubernetes/accesscontrol_clientset.go +++ b/pkg/kubernetes/accesscontrol_clientset.go @@ -55,6 +55,22 @@ func (a *AccessControlClientset) NodesLogs(ctx context.Context, name, logPath st AbsPath(url...), 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) { + return nil, isNotAllowedError(gvk) + } + + if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { + return nil, fmt.Errorf("failed to get node %s: %w", name, err) + } + + url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"} + return a.delegate.CoreV1().RESTClient(). + Get(). + AbsPath(url...), nil +} + func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) { gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} if !isAllowed(a.staticConfig, gvk) { diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go index 76d9cc92..be3861ba 100644 --- a/pkg/kubernetes/nodes.go +++ b/pkg/kubernetes/nodes.go @@ -34,3 +34,25 @@ func (k *Kubernetes) NodesLog(ctx context.Context, name string, logPath string, return string(rawData), nil } + +func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string, error) { + // Use the node proxy API to access stats summary from the kubelet + // This endpoint provides CPU, memory, filesystem, and network statistics + + req, err := k.AccessControlClientset().NodesStatsSummary(ctx, name) + if err != nil { + return "", err + } + + result := req.Do(ctx) + if result.Error() != nil { + return "", fmt.Errorf("failed to get node stats summary: %w", result.Error()) + } + + rawData, err := result.Raw() + if err != nil { + return "", fmt.Errorf("failed to read node stats summary response: %w", err) + } + + return string(rawData), nil +} diff --git a/pkg/mcp/nodes_test.go b/pkg/mcp/nodes_test.go index ce2cbc7e..3220b8ec 100644 --- a/pkg/mcp/nodes_test.go +++ b/pkg/mcp/nodes_test.go @@ -200,6 +200,115 @@ func (s *NodesSuite) TestNodesLogDenied() { }) } +func (s *NodesSuite) TestNodesStatsSummary() { + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Get Node response + if req.URL.Path == "/api/v1/nodes/existing-node" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Node", + "metadata": { + "name": "existing-node" + } + }`)) + return + } + // Get Stats Summary response + if req.URL.Path == "/api/v1/nodes/existing-node/proxy/stats/summary" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "node": { + "nodeName": "existing-node", + "cpu": { + "time": "2025-10-27T00:00:00Z", + "usageNanoCores": 1000000000, + "usageCoreNanoSeconds": 5000000000 + }, + "memory": { + "time": "2025-10-27T00:00:00Z", + "availableBytes": 8000000000, + "usageBytes": 4000000000, + "workingSetBytes": 3500000000 + } + }, + "pods": [] + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + s.InitMcpClient() + s.Run("nodes_stats_summary(name=nil)", func() { + toolResult, err := s.CallTool("nodes_stats_summary", 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 missing name", func() { + expectedMessage := "failed to get node stats summary, missing argument name" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + s.Run("nodes_stats_summary(name=inexistent-node)", func() { + toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{ + "name": "inexistent-node", + }) + 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 missing node", func() { + expectedMessage := "failed to get node stats summary for inexistent-node: failed to get node inexistent-node: the server could not find the requested resource (get nodes inexistent-node)" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + s.Run("nodes_stats_summary(name=existing-node)", func() { + toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{ + "name": "existing-node", + }) + 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 stats summary", func() { + content := toolResult.Content[0].(mcp.TextContent).Text + s.Containsf(content, "existing-node", "expected stats to contain node name, got %v", content) + s.Containsf(content, "usageNanoCores", "expected stats to contain CPU metrics, got %v", content) + s.Containsf(content, "usageBytes", "expected stats to contain memory metrics, got %v", content) + }) + }) +} + +func (s *NodesSuite) TestNodesStatsSummaryDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Node" } ] + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + s.Run("nodes_stats_summary (denied)", func() { + toolResult, err := s.CallTool("nodes_stats_summary", map[string]interface{}{ + "name": "does-not-matter", + }) + 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 node stats summary for does-not-matter: resource not allowed: /v1, Kind=Node" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + func TestNodes(t *testing.T) { suite.Run(t, new(NodesSuite)) } diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index 37345100..13e25510 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -67,6 +67,29 @@ }, "name": "nodes_log" }, + { + "annotations": { + "title": "Node: Stats Summary", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the node to get stats from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "nodes_stats_summary" + }, { "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 7041bd3f..c3e2f64d 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -237,6 +237,37 @@ }, "name": "nodes_log" }, + { + "annotations": { + "title": "Node: Stats Summary", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "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 stats from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "nodes_stats_summary" + }, { "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 a454f1ef..88f4aa99 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -213,6 +213,33 @@ }, "name": "nodes_log" }, + { + "annotations": { + "title": "Node: Stats Summary", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "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 stats from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "nodes_stats_summary" + }, { "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 5e5fa4ea..e9c15460 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -173,6 +173,29 @@ }, "name": "nodes_log" }, + { + "annotations": { + "title": "Node: Stats Summary", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the node to get stats from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "nodes_stats_summary" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 56a160ed..9dd61fe3 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -173,6 +173,29 @@ }, "name": "nodes_log" }, + { + "annotations": { + "title": "Node: Stats Summary", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the node to get stats from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "nodes_stats_summary" + }, { "annotations": { "title": "Pods: Delete", diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index 6c669398..54785ca6 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -44,6 +44,27 @@ func initNodes() []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: nodesLog}, + {Tool: api.Tool{ + Name: "nodes_stats_summary", + Description: "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", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the node to get stats from", + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Node: Stats Summary", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: nodesStatsSummary}, } } @@ -78,3 +99,15 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } return api.NewToolCallResult(ret, nil), nil } + +func nodesStatsSummary(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + name, ok := params.GetArguments()["name"].(string) + if !ok || name == "" { + return api.NewToolCallResult("", errors.New("failed to get node stats summary, missing argument name")), nil + } + ret, err := params.NodesStatsSummary(params, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get node stats summary for %s: %v", name, err)), nil + } + return api.NewToolCallResult(ret, nil), nil +}