From 87ee704049fa8677857c0378e0f0ad8132ff7489 Mon Sep 17 00:00:00 2001 From: Praneeth Shetty Date: Thu, 14 Aug 2025 15:58:43 +0530 Subject: [PATCH] feat:(helm) added a new tool for getting the values.yaml of the chart Signed-off-by: Praneeth Shetty --- pkg/helm/helm.go | 45 +++++- pkg/mcp/helm.go | 39 +++++ pkg/mcp/helm_test.go | 135 +++++++++++++++++- .../helm-chart-with-values/Chart.yaml | 5 + .../helm-chart-with-values/values.yaml | 54 +++++++ 5 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 pkg/mcp/testdata/helm-chart-with-values/Chart.yaml create mode 100644 pkg/mcp/testdata/helm-chart-with-values/values.yaml diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 186b50df..2c22c087 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -2,16 +2,18 @@ package helm import ( "context" + "encoding/json" "fmt" + "log" + "time" + "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 { @@ -140,3 +142,42 @@ func simplify(release ...*release.Release) []map[string]interface{} { } return ret } + +func (h *Helm) GetChartValues(ctx context.Context, chart string, version string) (string, error) { + // Create a show action to get chart values + cfg, err := h.newAction("", false) + if err != nil { + return "", err + } + + // Create a show action to get chart values with configuration + show := action.NewShowWithConfig(action.ShowValues, cfg) + show.Version = version + + // Locate the chart + chartRequested, err := show.LocateChart(chart, cli.New()) + if err != nil { + return "", err + } + + // Load the chart + chartLoaded, err := loader.Load(chartRequested) + if err != nil { + return "", err + } + + // Get the values from the chart + values := chartLoaded.Values + if values == nil { + return "No values found for chart", nil + } + + // Convert values to YAML + ret, err := json.MarshalIndent(values, "", " ") + + if err != nil { + return "", err + } + + return string(ret), nil +} diff --git a/pkg/mcp/helm.go b/pkg/mcp/helm.go index e2659653..33185cef 100644 --- a/pkg/mcp/helm.go +++ b/pkg/mcp/helm.go @@ -44,6 +44,17 @@ func (s *Server) initHelm() []server.ServerTool { mcp.WithIdempotentHintAnnotation(true), mcp.WithOpenWorldHintAnnotation(true), ), Handler: s.helmUninstall}, + {Tool: mcp.NewTool("helm_values", + mcp.WithDescription("Retrieves the default or overridden values.yaml for a specified Helm chart version. Accepts a chart reference (e.g., stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress) and an optional chart version. If no version is provided, the latest available version is used."), + mcp.WithString("chart", mcp.Description("Chart reference to extract values from, such as stable/grafana or oci://ghcr.io/nginxinc/charts/nginx-ingress"), mcp.Required()), + mcp.WithString("version", mcp.Description("Version of the Helm chart to retrieve values for. Optional; defaults to the latest version if not provided.")), + // Tool annotations + mcp.WithTitleAnnotation("Helm: Values"), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.helmChartValues}, } } @@ -116,3 +127,31 @@ func (s *Server) helmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*m } return NewTextResult(ret, err), nil } + +func (s *Server) helmChartValues(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var chart string + ok := false + if chart, ok = ctr.GetArguments()["chart"].(string); !ok { + return NewTextResult("", fmt.Errorf("missing required argument: chart")), nil + } + + version := "" + if v, ok := ctr.GetArguments()["version"].(string); ok { + version = v + } + + derived, err := s.k.Derived(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initialize derived context: %w", err) + } + + ret, err := derived.NewHelm().GetChartValues(ctx, chart, version) + if err != nil { + if version != "" { + return NewTextResult("", fmt.Errorf("failed to retrieve values for Helm chart '%s' (version %s): %w", chart, version, err)), nil + } + return NewTextResult("", fmt.Errorf("failed to retrieve values for Helm chart '%s': %w", chart, err)), nil + } + + return NewTextResult(ret, nil), nil +} diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index 2195b20a..6b288ee9 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -3,17 +3,18 @@ package mcp import ( "context" "encoding/base64" + "path/filepath" + "runtime" + "strings" + "testing" + "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/mark3labs/mcp-go/mcp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - "path/filepath" - "runtime" "sigs.k8s.io/yaml" - "strings" - "testing" ) func TestHelmInstall(t *testing.T) { @@ -254,6 +255,132 @@ func TestHelmUninstallDenied(t *testing.T) { }) } +func TestHelmValues(t *testing.T) { + testCase(t, func(c *mcpContext) { + c.withEnvTest() + _, file, _, _ := runtime.Caller(0) + chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-with-values") + + t.Run("helm_values with local chart returns values", func(t *testing.T) { + toolResult, err := c.callTool("helm_values", map[string]interface{}{ + "chart": chartPath, + }) + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if toolResult.IsError { + t.Fatalf("call tool failed: %v", toolResult.Content[0].(mcp.TextContent).Text) + } + + // Parse the returned YAML + var values map[string]interface{} + err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &values) + if err != nil { + t.Fatalf("invalid tool result content %v", err) + } + + // Verify some expected values + if values["replicaCount"] != float64(1) { + t.Fatalf("expected replicaCount to be 1, got %v", values["replicaCount"]) + } + + if imageMap, ok := values["image"].(map[string]interface{}); ok { + if imageMap["repository"] != "nginx" { + t.Fatalf("expected image.repository to be nginx, got %v", imageMap["repository"]) + } + if imageMap["tag"] != "latest" { + t.Fatalf("expected image.tag to be latest, got %v", imageMap["tag"]) + } + } else { + t.Fatalf("expected image to be a map, got %T", values["image"]) + } + + if customConfig, ok := values["customConfig"].(map[string]interface{}); ok { + if customConfig["debug"] != false { + t.Fatalf("expected customConfig.debug to be false, got %v", customConfig["debug"]) + } + if customConfig["logLevel"] != "info" { + t.Fatalf("expected customConfig.logLevel to be info, got %v", customConfig["logLevel"]) + } + } else { + t.Fatalf("expected customConfig to be a map, got %T", values["customConfig"]) + } + }) + + t.Run("helm_values with version parameter", func(t *testing.T) { + toolResult, err := c.callTool("helm_values", map[string]interface{}{ + "chart": chartPath, + "version": "1.0.0", + }) + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if toolResult.IsError { + t.Fatalf("call tool failed: %v", toolResult.Content[0].(mcp.TextContent).Text) + } + + // Should still return values even with version specified + var values map[string]interface{} + err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &values) + if err != nil { + t.Fatalf("invalid tool result content %v", err) + } + + if values["replicaCount"] != float64(1) { + t.Fatalf("expected replicaCount to be 1, got %v", values["replicaCount"]) + } + }) + + t.Run("helm_values with chart without values returns no values message", func(t *testing.T) { + chartPathNoValues := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op") + toolResult, err := c.callTool("helm_values", map[string]interface{}{ + "chart": chartPathNoValues, + }) + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if toolResult.IsError { + t.Fatalf("call tool failed: %v", toolResult.Content[0].(mcp.TextContent).Text) + } + + if toolResult.Content[0].(mcp.TextContent).Text != "No values found for chart" { + t.Fatalf("expected 'No values found for chart', got %v", toolResult.Content[0].(mcp.TextContent).Text) + } + }) + + t.Run("helm_values with missing chart argument returns error", func(t *testing.T) { + toolResult, err := c.callTool("helm_values", map[string]interface{}{}) + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if !toolResult.IsError { + t.Fatalf("expected tool to fail with missing chart argument") + } + + expectedError := "missing required argument: chart" + if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, expectedError) { + t.Fatalf("expected error to contain '%s', got %v", expectedError, toolResult.Content[0].(mcp.TextContent).Text) + } + }) + + t.Run("helm_values with invalid chart path returns error", func(t *testing.T) { + toolResult, err := c.callTool("helm_values", map[string]interface{}{ + "chart": "/non/existent/path", + }) + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if !toolResult.IsError { + t.Fatalf("expected tool to fail with invalid chart path") + } + + if !strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, "failed to retrieve values for Helm chart") { + t.Fatalf("expected error to contain failure message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + } + }) + }) +} + func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) { secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{}) for _, secret := range secrets.Items { diff --git a/pkg/mcp/testdata/helm-chart-with-values/Chart.yaml b/pkg/mcp/testdata/helm-chart-with-values/Chart.yaml new file mode 100644 index 00000000..01c13dea --- /dev/null +++ b/pkg/mcp/testdata/helm-chart-with-values/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: test-chart-with-values +version: 1.0.0 +type: application +description: Test chart with values for testing helm_values tool diff --git a/pkg/mcp/testdata/helm-chart-with-values/values.yaml b/pkg/mcp/testdata/helm-chart-with-values/values.yaml new file mode 100644 index 00000000..6510a9c9 --- /dev/null +++ b/pkg/mcp/testdata/helm-chart-with-values/values.yaml @@ -0,0 +1,54 @@ +# Default values for test-chart-with-values. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + tag: "latest" + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: Prefix + tls: [] + +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +# Custom configuration values +customConfig: + debug: false + logLevel: "info" + features: + - authentication + - authorization + database: + host: "localhost" + port: 5432 + name: "myapp"