refactor(helm): adapt Helm contribution to project structure

This commit is contained in:
Marc Nuri
2025-05-09 18:49:00 +02:00
parent 34eabdef13
commit b4928f8230
12 changed files with 236 additions and 207 deletions

View File

@@ -28,6 +28,8 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
- **✅ Namespaces**: List Kubernetes Namespaces.
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
- **✅ Projects**: List OpenShift Projects.
- **☸️ Helm**:
- **List** Helm releases in all namespaces or in a specific namespace.
Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.

68
go.mod
View File

@@ -4,17 +4,17 @@ go 1.24.1
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/mark3labs/mcp-go v0.21.1
github.com/mark3labs/mcp-go v0.26.0
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/net v0.39.0
gopkg.in/yaml.v3 v3.0.1
golang.org/x/net v0.40.0
helm.sh/helm/v3 v3.17.3
k8s.io/api v0.32.3
k8s.io/apiextensions-apiserver v0.32.3
k8s.io/apimachinery v0.32.3
k8s.io/client-go v0.32.3
k8s.io/api v0.33.0
k8s.io/apiextensions-apiserver v0.33.0
k8s.io/apimachinery v0.33.0
k8s.io/cli-runtime v0.32.2
k8s.io/client-go v0.33.0
k8s.io/klog/v2 v2.130.1
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
sigs.k8s.io/controller-runtime v0.20.4
@@ -67,15 +67,13 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -85,7 +83,7 @@ require (
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
@@ -110,9 +108,9 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rubenv/sql-migrate v1.7.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -129,31 +127,33 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/grpc v1.68.1 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiserver v0.32.3 // indirect
k8s.io/cli-runtime v0.32.2 // indirect
k8s.io/component-base v0.32.3 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiserver v0.33.0 // indirect
k8s.io/component-base v0.33.0 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/kubectl v0.32.2 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.18.0 // indirect
sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
)

View File

@@ -1,50 +1,52 @@
package helm
import (
"context"
"log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
"k8s.io/cli-runtime/pkg/genericclioptions"
"log"
"sigs.k8s.io/yaml"
)
// Helm provides methods to interact with Helm releases
// Mirrors the abstraction style of pkg/kubernetes
type Helm struct {
settings *cli.EnvSettings
type Kubernetes interface {
genericclioptions.RESTClientGetter
NamespaceOrDefault(namespace string) string
}
// NewHelm creates a new Helm instance using kubeconfig, context, and namespace settings
func NewHelm(kubeconfig, kubeContext, namespace string) *Helm {
type Helm struct {
kubernetes Kubernetes
}
// NewHelm creates a new Helm instance
func NewHelm(kubernetes Kubernetes, namespace string) *Helm {
settings := cli.New()
if kubeconfig != "" {
settings.KubeConfig = kubeconfig
}
if kubeContext != "" {
settings.KubeContext = kubeContext
}
if namespace != "" {
settings.SetNamespace(namespace)
}
return &Helm{settings: settings}
return &Helm{kubernetes: kubernetes}
}
// ReleasesList lists Helm releases in a specific namespace (or all namespaces if namespace is empty)
func (h *Helm) ReleasesList(ctx context.Context, namespace string) ([]*release.Release, error) {
// If no namespace is given, use the default from kubeconfig
if namespace == "" {
namespace = h.settings.Namespace()
}
// ReleasesList lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces.
func (h *Helm) ReleasesList(namespace string, allNamespaces bool) (string, error) {
cfg := new(action.Configuration)
if err := cfg.Init(h.settings.RESTClientGetter(), namespace, "", log.Printf); err != nil {
return nil, err
applicableNamespace := ""
if !allNamespaces {
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
}
if err := cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf); err != nil {
return "", err
}
list := action.NewList(cfg)
// To list across all namespaces, set AllNamespaces to true
if namespace == "" || namespace == "all" {
list.AllNamespaces = true
list.AllNamespaces = allNamespaces
releases, err := list.Run()
if err != nil {
return "", err
} else if len(releases) == 0 {
return "No Helm releases found", nil
}
return list.Run()
ret, err := yaml.Marshal(releases)
if err != nil {
return "", err
}
return string(ret), nil
}

View File

@@ -1,40 +0,0 @@
package helm
import (
"context"
"testing"
)
func TestNewHelm(t *testing.T) {
h := NewHelm("", "", "")
if h == nil {
t.Fatal("expected non-nil Helm instance")
}
if h.settings == nil {
t.Fatal("expected non-nil settings in Helm instance")
}
}
func TestHelm_ReleasesList_DefaultNamespace(t *testing.T) {
h := NewHelm("", "", "")
// Use a namespace that is likely to exist, or leave empty for default
releases, err := h.ReleasesList(context.Background(), "")
if err != nil {
t.Skipf("skipping: could not list releases (likely no cluster/helm configured): %v", err)
}
// No assertion on releases count, just check type
if releases == nil {
t.Error("expected releases slice, got nil")
}
}
func TestHelm_ReleasesList_AllNamespaces(t *testing.T) {
h := NewHelm("", "", "")
releases, err := h.ReleasesList(context.Background(), "all")
if err != nil {
t.Skipf("skipping: could not list all releases (likely no cluster/helm configured): %v", err)
}
if releases == nil {
t.Error("expected releases slice, got nil")
}
}

View File

@@ -52,6 +52,30 @@ func (k *Kubernetes) IsInCluster() bool {
return err == nil && cfg != nil
}
func (k *Kubernetes) configuredNamespace() string {
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
return ns
}
return ""
}
func (k *Kubernetes) NamespaceOrDefault(namespace string) string {
if namespace == "" {
return k.configuredNamespace()
}
return namespace
}
// ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter)
func (k *Kubernetes) ToRESTConfig() (*rest.Config, error) {
return k.cfg, nil
}
// ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter)
func (k *Kubernetes) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return k.clientCmdConfig
}
func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
var cfg clientcmdapi.Config
var err error

View File

@@ -2,7 +2,9 @@ package kubernetes
import (
"github.com/fsnotify/fsnotify"
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/discovery"
@@ -26,9 +28,10 @@ type Kubernetes struct {
scheme *runtime.Scheme
parameterCodec runtime.ParameterCodec
clientSet kubernetes.Interface
discoveryClient *discovery.DiscoveryClient
discoveryClient discovery.CachedDiscoveryInterface
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
dynamicClient *dynamic.DynamicClient
Helm *helm.Helm
}
func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
@@ -43,10 +46,11 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
if err != nil {
return nil, err
}
k8s.discoveryClient, err = discovery.NewDiscoveryClientForConfig(k8s.cfg)
discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8s.cfg)
if err != nil {
return nil, err
}
k8s.discoveryClient = memory.NewMemCacheClient(discoveryClient)
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(k8s.discoveryClient))
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
if err != nil {
@@ -57,6 +61,7 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
return nil, err
}
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
k8s.Helm = helm.NewHelm(k8s, "TODO")
return k8s, nil
}
@@ -102,6 +107,14 @@ func (k *Kubernetes) Close() {
}
}
func (k *Kubernetes) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return k.discoveryClient, nil
}
func (k *Kubernetes) ToRESTMapper() (meta.RESTMapper, error) {
return k.deferredDiscoveryRESTMapper, nil
}
func marshal(v any) (string, error) {
switch t := v.(type) {
case []unstructured.Unstructured:
@@ -123,37 +136,3 @@ func marshal(v any) (string, error) {
}
return string(ret), nil
}
// KubeconfigPath returns the kubeconfig path used by this Kubernetes client
func (k *Kubernetes) KubeconfigPath() string {
return k.Kubeconfig
}
// CurrentContext returns the current context from the kubeconfig
func (k *Kubernetes) CurrentContext() string {
if k.clientCmdConfig == nil {
return ""
}
if rawConfig, err := k.clientCmdConfig.RawConfig(); err == nil {
return rawConfig.CurrentContext
}
return ""
}
// ConfiguredNamespace returns the namespace configured in the kubeconfig/context
func (k *Kubernetes) ConfiguredNamespace() string {
if k.clientCmdConfig == nil {
return ""
}
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
return ns
}
return ""
}
func (k *Kubernetes) namespaceOrDefault(namespace string) string {
if namespace == "" {
return k.ConfiguredNamespace()
}
return namespace
}

View File

@@ -32,11 +32,11 @@ func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string)
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, k.namespaceOrDefault(namespace), name)
}, k.NamespaceOrDefault(namespace), name)
}
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
@@ -79,7 +79,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
tailLines := int64(256)
req := k.clientSet.CoreV1().Pods(k.namespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
req := k.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
TailLines: &tailLines,
Container: container,
})
@@ -108,7 +108,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
var resources []any
pod := &v1.Pod{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
Spec: v1.PodSpec{Containers: []v1.Container{{
Name: name,
Image: image,
@@ -120,7 +120,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
resources = append(resources, &v1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
Spec: v1.ServiceSpec{
Selector: labels,
Type: v1.ServiceTypeClusterIP,
@@ -135,7 +135,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
"kind": "Route",
"metadata": map[string]interface{}{
"name": name,
"namespace": k.namespaceOrDefault(namespace),
"namespace": k.NamespaceOrDefault(namespace),
"labels": labels,
},
"spec": map[string]interface{}{
@@ -175,7 +175,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err

View File

@@ -34,7 +34,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
}
rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
@@ -64,7 +64,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
@@ -77,7 +77,7 @@ func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersion
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = k.ConfiguredNamespace()
namespace = k.configuredNamespace()
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
}
@@ -92,7 +92,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
}
resources[i], rErr = k.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
FieldManager: version.BinaryName,

View File

@@ -5,64 +5,33 @@ import (
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gopkg.in/yaml.v3"
)
func (s *Server) initHelm() []server.ServerTool {
rets := make([]server.ServerTool, 0)
rets = append(rets, server.ServerTool{
Tool: mcp.NewTool("helm_list",
mcp.WithDescription("List all Helm releases in all namespaces."),
mcp.WithDescription("List all of the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
),
Handler: s.helmReleasesList,
})
rets = append(rets, server.ServerTool{
Tool: mcp.NewTool("helm_list_in_namespace",
mcp.WithDescription("List all Helm releases in the specified namespace."),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from."), mcp.Required()),
),
Handler: s.helmListInNamespace,
Handler: s.helmList,
})
return rets
}
func (s *Server) helmReleasesList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
releases, err := s.helm.ReleasesList(ctx, "")
func (s *Server) helmList(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
allNamespaces := false
if v, ok := ctr.Params.Arguments["all_namespaces"].(bool); ok {
allNamespaces = v
}
namespace := ""
if v, ok := ctr.Params.Arguments["namespace"].(string); ok {
namespace = v
}
ret, err := s.k.Helm.ReleasesList(namespace, allNamespaces)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases: %w", err)), nil
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
for _, r := range releases {
if r != nil && r.Chart != nil {
r.Chart.Templates = nil
r.Chart.Files = nil
}
}
out, err := yaml.Marshal(releases)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to marshal helm releases: %w", err)), nil
}
return NewTextResult(string(out), nil), nil
}
// helmListInNamespace lists Helm releases in a specified namespace
func (s *Server) helmListInNamespace(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := req.Params.Arguments["namespace"]
if ns == nil || ns.(string) == "" {
return NewTextResult("", fmt.Errorf("missing required argument: namespace")), nil
}
releases, err := s.helm.ReleasesList(ctx, ns.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace %s: %w", ns, err)), nil
}
for _, r := range releases {
if r != nil && r.Chart != nil {
r.Chart.Templates = nil
r.Chart.Files = nil
}
}
out, err := yaml.Marshal(releases)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to marshal helm releases: %w", err)), nil
}
return NewTextResult(string(out), nil), nil
return NewTextResult(ret, err), nil
}

100
pkg/mcp/helm_test.go Normal file
View File

@@ -0,0 +1,100 @@
package mcp
import (
"encoding/base64"
"github.com/mark3labs/mcp-go/mcp"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
"testing"
)
func TestHelmList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, "release-to-list", metav1.DeleteOptions{})
toolResult, err := c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with no releases, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
_, err = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "release-to-list",
Labels: map[string]string{"owner": "helm"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"release-to-list\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
toolResult, err = c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with deployed release, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
t.Run("helm_list with deployed release in other namespaces, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
t.Run("helm_list with deployed release in all namespaces, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
}
})
})
}

View File

@@ -1,7 +1,6 @@
package mcp
import (
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
"github.com/mark3labs/mcp-go/mcp"
@@ -17,7 +16,6 @@ type Server struct {
configuration *Configuration
server *server.MCPServer
k *kubernetes.Kubernetes
helm *helm.Helm
}
func NewSever(configuration Configuration) (*Server, error) {
@@ -35,13 +33,6 @@ func NewSever(configuration Configuration) (*Server, error) {
if err := s.reloadKubernetesClient(); err != nil {
return nil, err
}
// After Kubernetes client is initialized, set up Helm with the same config
if s.k != nil {
kubeconfig := s.k.KubeconfigPath()
kubeContext := s.k.CurrentContext()
namespace := s.k.ConfiguredNamespace()
s.helm = helm.NewHelm(kubeconfig, kubeContext, namespace)
}
s.k.WatchKubeConfig(s.reloadKubernetesClient)
return s, nil
}

View File

@@ -54,6 +54,7 @@ func TestTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"helm_list",
"namespaces_list",
"pods_list",
"pods_list_in_namespace",
@@ -61,6 +62,7 @@ func TestTools(t *testing.T) {
"pods_delete",
"pods_log",
"pods_run",
"pods_exec",
"resources_list",
"resources_get",
"resources_create_or_update",