feat(pods): pods_top retrieves Pod resource consumption (metrics API) (119)

feat(pods): pods_top retrieves Pod resource consumption (metrics API)
---
doc(pods): pods_top retrieves Pod resource consumption (metrics API)
This commit is contained in:
Marc Nuri
2025-06-16 12:07:36 +02:00
committed by GitHub
parent 84782048a6
commit 1a4605dc2d
7 changed files with 303 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
- **Get** a pod by name from the specified namespace.
- **Delete** a pod by name from the specified namespace.
- **Show logs** for a pod by name from the specified namespace.
- **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace.
- **Exec** into a pod and run a command.
- **Run** a container image in a pod and optionally expose it.
- **✅ Namespaces**: List Kubernetes Namespaces.
@@ -314,6 +315,23 @@ Run a Kubernetes Pod in the current or provided namespace with the provided cont
- TCP/IP port to expose from the Pod container
- No port exposed if not provided
### `pods_top`
Lists the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace
**Parameters:**
- `all_namespaces` (`boolean`, optional, default: `true`)
- If `true`, lists resource consumption for Pods in all namespaces
- If `false`, lists resource consumption for Pods in the configured or provided namespace
- `namespace` (`string`, optional)
- Namespace to list the Pod resources from
- If not provided, will list Pods from the configured namespace (in case all_namespaces is false)
- `name` (`string`, optional)
- Name of the Pod to get resource consumption from
- If not provided, will list resource consumption for all Pods in the applicable namespace(s)
- `label_selector` (`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 (Optional, only applicable when name is not provided)
### `projects_list`
List all the OpenShift projects in the current cluster

3
go.mod
View File

@@ -18,6 +18,8 @@ require (
k8s.io/cli-runtime v0.33.1
k8s.io/client-go v0.33.1
k8s.io/klog/v2 v2.130.1
k8s.io/kubectl v0.33.0
k8s.io/metrics v0.33.0
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
sigs.k8s.io/controller-runtime v0.21.0
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
@@ -126,7 +128,6 @@ require (
k8s.io/apiserver v0.33.1 // indirect
k8s.io/component-base v0.33.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/kubectl v0.33.0 // indirect
oras.land/oras-go/v2 v2.5.0 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.19.0 // indirect

2
go.sum
View File

@@ -462,6 +462,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/kubectl v0.33.0 h1:HiRb1yqibBSCqic4pRZP+viiOBAnIdwYDpzUFejs07g=
k8s.io/kubectl v0.33.0/go.mod h1:gAlGBuS1Jq1fYZ9AjGWbI/5Vk3M/VW2DK4g10Fpyn/0=
k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y=
k8s.io/metrics v0.33.0/go.mod h1:XewckTFXmE2AJiP7PT3EXaY7hi7bler3t2ZLyOdQYzU=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=

View File

@@ -3,7 +3,11 @@ package kubernetes
import (
"bytes"
"context"
"errors"
"fmt"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
v1 "k8s.io/api/core/v1"
@@ -18,6 +22,13 @@ import (
"k8s.io/client-go/tools/remotecommand"
)
type PodsTopOptions struct {
metav1.ListOptions
AllNamespaces bool
Namespace string
Name string
}
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
@@ -175,6 +186,38 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
return k.resourcesCreateOrUpdate(ctx, toCreate)
}
func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, 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")
}
namespace := options.Namespace
if options.AllNamespaces && namespace == "" {
namespace = ""
} else {
namespace = k.NamespaceOrDefault(namespace)
}
metricsClient, err := metricsclientset.NewForConfig(k.cfg)
if err != nil {
return nil, fmt.Errorf("failed to create metrics client: %w", err)
}
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
if options.Name != "" {
m, err := metricsClient.MetricsV1beta1().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err)
}
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
} else {
versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, options.ListOptions)
if err != nil {
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
}
}
convertedMetrics := &metrics.PodMetricsList{}
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})

View File

@@ -1,11 +1,13 @@
package mcp
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"k8s.io/kubectl/pkg/metricsutil"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -53,6 +55,19 @@ func (s *Server) initPods() []server.ServerTool {
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsDelete},
{Tool: mcp.NewTool("pods_top",
mcp.WithDescription("List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace"),
mcp.WithBoolean("all_namespaces", mcp.Description("If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace"), mcp.DefaultBool(true)),
mcp.WithString("namespace", mcp.Description("Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)")),
mcp.WithString("name", mcp.Description("Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)")),
mcp.WithString("label_selector", mcp.Description("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 (Optional, only applicable when name is not provided)"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
// Tool annotations
mcp.WithTitleAnnotation("Pods: Top"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsTop},
{Tool: mcp.NewTool("pods_exec",
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
mcp.WithString("namespace", mcp.Description("Namespace of the Pod where the command will be executed")),
@@ -125,10 +140,10 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques
if ns == nil {
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
}
labelSelector := ctr.GetArguments()["labelSelector"]
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
labelSelector := ctr.GetArguments()["labelSelector"]
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
}
@@ -171,6 +186,33 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.
return NewTextResult(ret, err), nil
}
func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
podsTopOptions.Namespace = v
}
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
podsTopOptions.AllNamespaces = v
}
if v, ok := ctr.GetArguments()["name"].(string); ok {
podsTopOptions.Name = v
}
if v, ok := ctr.GetArguments()["label_selector"].(string); ok {
podsTopOptions.LabelSelector = v
}
ret, err := s.k.Derived(ctx).PodsTop(ctx, podsTopOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
buf := new(bytes.Buffer)
printer := metricsutil.NewTopCmdPrinter(buf)
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
}
return NewTextResult(buf.String(), nil), nil
}
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.GetArguments()["namespace"]
if ns == nil {

View File

@@ -97,6 +97,5 @@ func TestPodsExec(t *testing.T) {
t.Errorf("expected container name not found %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text)
}
})
})
}

195
pkg/mcp/pods_top_test.go Normal file
View File

@@ -0,0 +1,195 @@
package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
"net/http"
"regexp"
"testing"
)
func TestPodsTop(t *testing.T) {
testCase(t, func(c *mcpContext) {
mockServer := NewMockServer()
defer mockServer.Close()
c.withKubeConfig(mockServer.config)
metricsApiAvailable := false
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
println("Request received:", req.Method, req.URL.Path) // TODO: REMOVE LINE
w.Header().Set("Content-Type", "application/json")
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
if req.URL.Path == "/api" {
if !metricsApiAvailable {
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
} else {
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
}
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
if req.URL.Path == "/apis" {
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
return
}
// Pod Metrics from all namespaces
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" {
if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi"}}]}` +
`]}`))
} else {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi"}}]},` +
`{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi"}}]}` +
`]}`))
}
return
}
// Pod Metrics from configured namespace
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi"}}]}` +
`]}`))
return
}
// Pod Metrics from ns-5 namespace
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" {
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
`{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}}]}` +
`]}`))
return
}
// Pod Metrics from ns-5 namespace with pod-ns-5-5 pod name
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" {
_, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` +
`"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` +
`"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi"}}]` +
`}`))
}
}))
podsTopMetricsApiUnavailable, err := c.callTool("pods_top", map[string]interface{}{})
t.Run("pods_top with metrics API not available", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if !podsTopMetricsApiUnavailable.IsError {
t.Errorf("call tool should have returned an error")
}
if podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text != "failed to get pods top: metrics API is not available" {
t.Errorf("call tool returned unexpected content: %s", podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text)
}
})
// Enable metrics API addon
metricsApiAvailable = true
d, _ := c.mcpServer.k.ToDiscoveryClient()
d.Invalidate() // Force discovery client to refresh
podsTopDefaults, err := c.callTool("pods_top", map[string]interface{}{})
t.Run("pods_top defaults returns pod metrics from all namespaces", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopDefaults.Content[0].(mcp.TextContent).Text
if podsTopDefaults.IsError {
t.Fatalf("call tool failed %s", textContent)
}
expectedHeaders := regexp.MustCompile("(?m)^\\s*NAMESPACE\\s+POD\\s+NAME\\s+CPU\\(cores\\)\\s+MEMORY\\(bytes\\)\\s*$")
if !expectedHeaders.MatchString(textContent) {
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent)
}
expectedRows := []string{
"default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi",
"default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi",
"ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi",
}
for _, row := range expectedRows {
if !regexp.MustCompile(row).MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
}
}
expectedTotal := regexp.MustCompile("(?m)^\\s+600m\\s+900Mi\\s*$")
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
})
podsTopConfiguredNamespace, err := c.callTool("pods_top", map[string]interface{}{
"all_namespaces": false,
})
t.Run("pods_top[allNamespaces=false] returns pod metrics from configured namespace", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopConfiguredNamespace.Content[0].(mcp.TextContent).Text
expectedRows := []string{
"default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi",
"default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi",
}
for _, row := range expectedRows {
if !regexp.MustCompile(row).MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
}
}
expectedTotal := regexp.MustCompile("(?m)^\\s+40m\\s+60Mi\\s*$")
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
})
podsTopNamespace, err := c.callTool("pods_top", map[string]interface{}{
"namespace": "ns-5",
})
t.Run("pods_top[namespace=ns-5] returns pod metrics from provided namespace", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopNamespace.Content[0].(mcp.TextContent).Text
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-1\\s+container-1\\s+10m\\s+20Mi")
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile("(?m)^\\s+10m\\s+20Mi\\s*$")
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
})
podsTopNamespaceName, err := c.callTool("pods_top", map[string]interface{}{
"namespace": "ns-5",
"name": "pod-ns-5-5",
})
t.Run("pods_top[namespace=ns-5,name=pod-ns-5-5] returns pod metrics from provided namespace and name", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopNamespaceName.Content[0].(mcp.TextContent).Text
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-5\\s+container-1\\s+13m\\s+37Mi")
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile("(?m)^\\s+13m\\s+37Mi\\s*$")
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
})
podsTopNamespaceLabelSelector, err := c.callTool("pods_top", map[string]interface{}{
"label_selector": "app=pod-ns-5-42",
})
t.Run("pods_top[label_selector=app=pod-ns-5-42] returns pod metrics from pods matching selector", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
textContent := podsTopNamespaceLabelSelector.Content[0].(mcp.TextContent).Text
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-42\\s+container-1\\s+42m\\s+42Mi")
if !expectedRow.MatchString(textContent) {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
}
expectedTotal := regexp.MustCompile("(?m)^\\s+42m\\s+42Mi\\s*$")
if !expectedTotal.MatchString(textContent) {
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
}
})
})
}