feat(helm): initial support for helm install

This commit is contained in:
Marc Nuri
2025-05-12 18:15:30 +02:00
parent 0284cdce29
commit 22669e72be
7 changed files with 209 additions and 25 deletions

View File

@@ -29,6 +29,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
- **✅ Projects**: List OpenShift Projects.
- **☸️ Helm**:
- **Install** a Helm chart in the current or provided namespace.
- **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.
@@ -163,6 +164,37 @@ List all the Kubernetes events in the current cluster from all namespaces
- `namespace` (`string`, optional)
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
### `helm_install`
Install a Helm chart in the current or provided namespace with the provided name and chart
**Parameters:**
- `chart` (`string`, required)
- Name of the Helm chart to install
- Can be a local path or a remote URL
- Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz`
- `values` (`object`, optional)
- Values to pass to the Helm chart
- Example: `{"key": "value"}`
- `name` (`string`, optional)
- Name of the Helm release
- Random name if not provided
- `namespace` (`string`, optional)
- Namespace to install the Helm chart in
- If not provided, will use the configured namespace
### `helm_list`
List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
**Parameters:**
- `namespace` (`string`, optional)
- Namespace to list the Helm releases from
- If not provided, will use the configured namespace
- `all_namespaces` (`boolean`, optional)
- If `true`, will list Helm releases from all namespaces
- If `false`, will list Helm releases from the specified namespace
### `namespaces_list`
List all the Kubernetes namespaces in the current cluster

View File

@@ -1,11 +1,16 @@
package helm
import (
"context"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/registry"
"helm.sh/helm/v3/pkg/release"
"k8s.io/cli-runtime/pkg/genericclioptions"
"log"
"sigs.k8s.io/yaml"
"time"
)
type Kubernetes interface {
@@ -18,22 +23,48 @@ type Helm struct {
}
// NewHelm creates a new Helm instance
func NewHelm(kubernetes Kubernetes, namespace string) *Helm {
settings := cli.New()
if namespace != "" {
settings.SetNamespace(namespace)
}
func NewHelm(kubernetes Kubernetes) *Helm {
return &Helm{kubernetes: kubernetes}
}
func (h *Helm) Install(ctx context.Context, chart string, values map[string]interface{}, name string, namespace string) (string, error) {
cfg, err := h.newAction(namespace, false)
if err != nil {
return "", err
}
install := action.NewInstall(cfg)
if name == "" {
install.GenerateName = true
install.ReleaseName, _, _ = install.NameAndChart([]string{chart})
} else {
install.ReleaseName = name
}
install.Namespace = h.kubernetes.NamespaceOrDefault(namespace)
install.Wait = true
install.Timeout = 5 * time.Minute
install.DryRun = false
chartRequested, err := install.ChartPathOptions.LocateChart(chart, cli.New())
if err != nil {
return "", err
}
chartLoaded, err := loader.Load(chartRequested)
installedRelease, err := install.RunWithContext(ctx, chartLoaded, values)
if err != nil {
return "", err
}
ret, err := yaml.Marshal(simplify(installedRelease))
if err != nil {
return "", err
}
return string(ret), nil
}
// List 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) List(namespace string, allNamespaces bool) (string, error) {
cfg := new(action.Configuration)
applicableNamespace := ""
if !allNamespaces {
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
}
if err := cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf); err != nil {
cfg, err := h.newAction(namespace, allNamespaces)
if err != nil {
return "", err
}
list := action.NewList(cfg)
@@ -44,9 +75,46 @@ func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
} else if len(releases) == 0 {
return "No Helm releases found", nil
}
ret, err := yaml.Marshal(releases)
ret, err := yaml.Marshal(simplify(releases...))
if err != nil {
return "", err
}
return string(ret), nil
}
func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) {
cfg := new(action.Configuration)
applicableNamespace := ""
if !allNamespaces {
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
}
registryClient, err := registry.NewClient()
if err != nil {
return nil, err
}
cfg.RegistryClient = registryClient
return cfg, cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf)
}
func simplify(release ...*release.Release) []map[string]interface{} {
ret := make([]map[string]interface{}, len(release))
for i, r := range release {
ret[i] = map[string]interface{}{
"name": r.Name,
"namespace": r.Namespace,
"revision": r.Version,
}
if r.Chart != nil {
ret[i]["chart"] = r.Chart.Metadata.Name
ret[i]["chartVersion"] = r.Chart.Metadata.Version
ret[i]["appVersion"] = r.Chart.Metadata.AppVersion
}
if r.Info != nil {
ret[i]["status"] = r.Info.Status.String()
if !r.Info.LastDeployed.IsZero() {
ret[i]["lastDeployed"] = r.Info.LastDeployed.Format(time.RFC1123Z)
}
}
}
return ret
}

View File

@@ -61,7 +61,7 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
return nil, err
}
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
k8s.Helm = helm.NewHelm(k8s, "TODO")
k8s.Helm = helm.NewHelm(k8s)
return k8s, nil
}

View File

@@ -8,16 +8,45 @@ import (
)
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 of the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
return []server.ServerTool{
{mcp.NewTool("helm_install",
mcp.WithDescription("Install a Helm chart in the current or provided namespace"),
mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()),
mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")),
mcp.WithString("name", mcp.Description("Name of the Helm release (Optional, random name if not provided)")),
mcp.WithString("namespace", mcp.Description("Namespace to install the Helm chart in (Optional, current namespace if not provided)")),
), s.helmInstall},
{mcp.NewTool("helm_list",
mcp.WithDescription("List all 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.helmList,
})
return rets
), s.helmList},
}
}
func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var chart string
ok := false
if chart, ok = ctr.Params.Arguments["chart"].(string); !ok {
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
}
values := map[string]interface{}{}
if v, ok := ctr.Params.Arguments["values"].(map[string]interface{}); ok {
values = v
}
name := ""
if v, ok := ctr.Params.Arguments["name"].(string); ok {
name = v
}
namespace := ""
if v, ok := ctr.Params.Arguments["namespace"].(string); ok {
namespace = v
}
ret, err := s.k.Helm.Install(ctx, chart, values, name, namespace)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
}
return NewTextResult(ret, err), nil
}
func (s *Server) helmList(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {

View File

@@ -5,14 +5,65 @@ import (
"github.com/mark3labs/mcp-go/mcp"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"path/filepath"
"runtime"
"sigs.k8s.io/yaml"
"strings"
"testing"
)
func TestHelmInstall(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
_, file, _, _ := runtime.Caller(0)
chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
toolResult, err := c.callTool("helm_install", map[string]interface{}{
"chart": chartPath,
})
t.Run("helm_install with local chart and no release name, returns installed chart", 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 !strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-") {
t.Fatalf("invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
}
if decoded[0]["namespace"] != "default" {
t.Fatalf("invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
}
if decoded[0]["chart"] != "no-op" {
t.Fatalf("invalid helm install name, expected release name, got empty")
}
if decoded[0]["chartVersion"] != "1.33.7" {
t.Fatalf("invalid helm install version, expected 1.33.7, got empty")
}
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm install status, expected deployed, got %v", decoded[0]["status"])
}
if decoded[0]["revision"] != float64(1) {
t.Fatalf("invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
}
})
})
}
func TestHelmList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
secrets, err := kc.CoreV1().Secrets("default").List(c.ctx, metav1.ListOptions{})
for _, secret := range secrets.Items {
if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") {
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, secret.Name, metav1.DeleteOptions{})
}
}
_ = 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) {
@@ -57,8 +108,8 @@ func TestHelmList(t *testing.T) {
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"])
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
@@ -92,8 +143,8 @@ func TestHelmList(t *testing.T) {
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"])
if decoded[0]["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
}
})
})

View File

@@ -54,6 +54,7 @@ func TestTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"helm_install",
"helm_list",
"namespaces_list",
"pods_list",

View File

@@ -0,0 +1,3 @@
apiVersion: v1
name: no-op
version: 1.33.7