feat(kubernetes): resources_list can list any resource in the cluster

This commit is contained in:
Marc Nuri
2025-02-17 12:29:04 +01:00
parent 6ae9247bae
commit b91f948cb4
4 changed files with 122 additions and 2 deletions

2
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/spf13/viper v1.19.0
golang.org/x/net v0.33.0
k8s.io/api v0.32.1
k8s.io/apiextensions-apiserver v0.32.0
k8s.io/apimachinery v0.32.1
k8s.io/client-go v0.32.1
sigs.k8s.io/controller-runtime v0.20.1
@@ -63,7 +64,6 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.32.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect

View File

@@ -22,7 +22,6 @@ const (
AppKubernetesPartOf = "app.kubernetes.io/part-of"
)
// TODO: WIP
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (string, error) {
client, err := dynamic.NewForConfig(k.cfg)
if err != nil {

View File

@@ -6,9 +6,25 @@ import (
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func (s *Sever) initResources() {
s.server.AddTool(mcp.NewTool(
"resources_list",
mcp.WithDescription("List Kubernetes resources in the current cluster by providing their apiVersion and kind and optionally the namespace"),
mcp.WithString("apiVersion",
mcp.Description("apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"),
mcp.Required(),
),
mcp.WithString("kind",
mcp.Description("kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)"),
mcp.Required(),
),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces"),
),
), resourcesList)
s.server.AddTool(mcp.NewTool(
"resources_create_or_update",
mcp.WithDescription("Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource"),
@@ -19,6 +35,34 @@ func (s *Sever) initResources() {
), resourcesCreateOrUpdate)
}
func resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := kubernetes.NewKubernetes()
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
apiVersion := ctr.Params.Arguments["apiVersion"]
if apiVersion == nil {
return NewTextResult("", errors.New("failed to list resources, missing argument apiVersion")), nil
}
kind := ctr.Params.Arguments["kind"]
if kind == nil {
return NewTextResult("", errors.New("failed to list resources, missing argument kind")), nil
}
namespace := ctr.Params.Arguments["namespace"]
if namespace == nil {
namespace = ""
}
gv, err := schema.ParseGroupVersion(apiVersion.(string))
if err != nil {
return NewTextResult("", errors.New("failed to list resources, invalid argument apiVersion")), nil
}
ret, err := k.ResourcesList(ctx, &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(ret, err), nil
}
func resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := kubernetes.NewKubernetes()
if err != nil {

View File

@@ -3,11 +3,88 @@ package mcp
import (
v1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
"testing"
)
func TestResourcesList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
t.Run("resources_list with missing apiVersion returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_list", map[string]interface{}{})
if !toolResult.IsError {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to list resources, missing argument apiVersion" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
t.Run("resources_list with missing kind returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1"})
if !toolResult.IsError {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to list resources, missing argument kind" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
t.Run("resources_list with invalid apiVersion returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"})
if !toolResult.IsError {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to list resources, invalid argument apiVersion" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
t.Run("resources_list with nonexistent apiVersion returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"})
if !toolResult.IsError {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != `failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
t.Run("resources_list returns namespaces", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if namespaces.IsError {
t.Fatalf("call tool failed")
return
}
})
var decodedNamespaces []unstructured.Unstructured
err = yaml.Unmarshal([]byte(namespaces.Content[0].(map[string]interface{})["text"].(string)), &decodedNamespaces)
t.Run("resources_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("resources_list returns more than 2 items", func(t *testing.T) {
if len(decodedNamespaces) < 3 {
t.Fatalf("invalid namespace count, expected >2, got %v", len(decodedNamespaces))
return
}
})
})
}
func TestResourcesCreateOrUpdate(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()