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
16 changes: 16 additions & 0 deletions pkg/kubernetes/accesscontrol_clientset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions pkg/kubernetes/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
109 changes: 109 additions & 0 deletions pkg/mcp/nodes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
23 changes: 23 additions & 0 deletions pkg/mcp/testdata/toolsets-core-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-openshift.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions pkg/toolsets/core/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}

Expand Down Expand Up @@ -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
}
Loading