Skip to content

Commit 1a4605d

Browse files
authored
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)
1 parent 8478204 commit 1a4605d

File tree

7 files changed

+303
-3
lines changed

7 files changed

+303
-3
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
2424
- **Get** a pod by name from the specified namespace.
2525
- **Delete** a pod by name from the specified namespace.
2626
- **Show logs** for a pod by name from the specified namespace.
27+
- **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace.
2728
- **Exec** into a pod and run a command.
2829
- **Run** a container image in a pod and optionally expose it.
2930
- **✅ Namespaces**: List Kubernetes Namespaces.
@@ -314,6 +315,23 @@ Run a Kubernetes Pod in the current or provided namespace with the provided cont
314315
- TCP/IP port to expose from the Pod container
315316
- No port exposed if not provided
316317

318+
### `pods_top`
319+
320+
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
321+
322+
**Parameters:**
323+
- `all_namespaces` (`boolean`, optional, default: `true`)
324+
- If `true`, lists resource consumption for Pods in all namespaces
325+
- If `false`, lists resource consumption for Pods in the configured or provided namespace
326+
- `namespace` (`string`, optional)
327+
- Namespace to list the Pod resources from
328+
- If not provided, will list Pods from the configured namespace (in case all_namespaces is false)
329+
- `name` (`string`, optional)
330+
- Name of the Pod to get resource consumption from
331+
- If not provided, will list resource consumption for all Pods in the applicable namespace(s)
332+
- `label_selector` (`string`, optional)
333+
- 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)
334+
317335
### `projects_list`
318336

319337
List all the OpenShift projects in the current cluster

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ require (
1818
k8s.io/cli-runtime v0.33.1
1919
k8s.io/client-go v0.33.1
2020
k8s.io/klog/v2 v2.130.1
21+
k8s.io/kubectl v0.33.0
22+
k8s.io/metrics v0.33.0
2123
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
2224
sigs.k8s.io/controller-runtime v0.21.0
2325
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
@@ -126,7 +128,6 @@ require (
126128
k8s.io/apiserver v0.33.1 // indirect
127129
k8s.io/component-base v0.33.1 // indirect
128130
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
129-
k8s.io/kubectl v0.33.0 // indirect
130131
oras.land/oras-go/v2 v2.5.0 // indirect
131132
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
132133
sigs.k8s.io/kustomize/api v0.19.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
462462
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
463463
k8s.io/kubectl v0.33.0 h1:HiRb1yqibBSCqic4pRZP+viiOBAnIdwYDpzUFejs07g=
464464
k8s.io/kubectl v0.33.0/go.mod h1:gAlGBuS1Jq1fYZ9AjGWbI/5Vk3M/VW2DK4g10Fpyn/0=
465+
k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y=
466+
k8s.io/metrics v0.33.0/go.mod h1:XewckTFXmE2AJiP7PT3EXaY7hi7bler3t2ZLyOdQYzU=
465467
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
466468
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
467469
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=

pkg/kubernetes/pods.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package kubernetes
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
8+
"k8s.io/metrics/pkg/apis/metrics"
9+
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
10+
metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned"
711

812
"github.com/manusa/kubernetes-mcp-server/pkg/version"
913
v1 "k8s.io/api/core/v1"
@@ -18,6 +22,13 @@ import (
1822
"k8s.io/client-go/tools/remotecommand"
1923
)
2024

25+
type PodsTopOptions struct {
26+
metav1.ListOptions
27+
AllNamespaces bool
28+
Namespace string
29+
Name string
30+
}
31+
2132
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
2233
return k.ResourcesList(ctx, &schema.GroupVersionKind{
2334
Group: "", Version: "v1", Kind: "Pod",
@@ -175,6 +186,38 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
175186
return k.resourcesCreateOrUpdate(ctx, toCreate)
176187
}
177188

189+
func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, error) {
190+
// TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
191+
if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
192+
return nil, errors.New("metrics API is not available")
193+
}
194+
namespace := options.Namespace
195+
if options.AllNamespaces && namespace == "" {
196+
namespace = ""
197+
} else {
198+
namespace = k.NamespaceOrDefault(namespace)
199+
}
200+
metricsClient, err := metricsclientset.NewForConfig(k.cfg)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to create metrics client: %w", err)
203+
}
204+
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
205+
if options.Name != "" {
206+
m, err := metricsClient.MetricsV1beta1().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{})
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err)
209+
}
210+
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
211+
} else {
212+
versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, options.ListOptions)
213+
if err != nil {
214+
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
215+
}
216+
}
217+
convertedMetrics := &metrics.PodMetricsList{}
218+
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
219+
}
220+
178221
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
179222
namespace = k.NamespaceOrDefault(namespace)
180223
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})

pkg/mcp/pods.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package mcp
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
89
"github.com/manusa/kubernetes-mcp-server/pkg/output"
10+
"k8s.io/kubectl/pkg/metricsutil"
911

1012
"github.com/mark3labs/mcp-go/mcp"
1113
"github.com/mark3labs/mcp-go/server"
@@ -53,6 +55,19 @@ func (s *Server) initPods() []server.ServerTool {
5355
mcp.WithIdempotentHintAnnotation(true),
5456
mcp.WithOpenWorldHintAnnotation(true),
5557
), Handler: s.podsDelete},
58+
{Tool: mcp.NewTool("pods_top",
59+
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"),
60+
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)),
61+
mcp.WithString("namespace", mcp.Description("Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)")),
62+
mcp.WithString("name", mcp.Description("Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)")),
63+
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]")),
64+
// Tool annotations
65+
mcp.WithTitleAnnotation("Pods: Top"),
66+
mcp.WithReadOnlyHintAnnotation(true),
67+
mcp.WithDestructiveHintAnnotation(false),
68+
mcp.WithIdempotentHintAnnotation(true),
69+
mcp.WithOpenWorldHintAnnotation(true),
70+
), Handler: s.podsTop},
5671
{Tool: mcp.NewTool("pods_exec",
5772
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
5873
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
125140
if ns == nil {
126141
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
127142
}
128-
labelSelector := ctr.GetArguments()["labelSelector"]
129143
resourceListOptions := kubernetes.ResourceListOptions{
130144
AsTable: s.configuration.ListOutput.AsTable(),
131145
}
146+
labelSelector := ctr.GetArguments()["labelSelector"]
132147
if labelSelector != nil {
133148
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
134149
}
@@ -171,6 +186,33 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.
171186
return NewTextResult(ret, err), nil
172187
}
173188

189+
func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
190+
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
191+
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
192+
podsTopOptions.Namespace = v
193+
}
194+
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
195+
podsTopOptions.AllNamespaces = v
196+
}
197+
if v, ok := ctr.GetArguments()["name"].(string); ok {
198+
podsTopOptions.Name = v
199+
}
200+
if v, ok := ctr.GetArguments()["label_selector"].(string); ok {
201+
podsTopOptions.LabelSelector = v
202+
}
203+
ret, err := s.k.Derived(ctx).PodsTop(ctx, podsTopOptions)
204+
if err != nil {
205+
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
206+
}
207+
buf := new(bytes.Buffer)
208+
printer := metricsutil.NewTopCmdPrinter(buf)
209+
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
210+
if err != nil {
211+
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
212+
}
213+
return NewTextResult(buf.String(), nil), nil
214+
}
215+
174216
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
175217
ns := ctr.GetArguments()["namespace"]
176218
if ns == nil {

pkg/mcp/pods_exec_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,5 @@ func TestPodsExec(t *testing.T) {
9797
t.Errorf("expected container name not found %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text)
9898
}
9999
})
100-
101100
})
102101
}

0 commit comments

Comments
 (0)