diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index ff31dc1..58232c7 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -205,9 +205,8 @@ func handleHelmUpgradeRelease(ctx context.Context, request *sdkmcp.CallToolReque if in.Set != "" { // Split multiple set values by comma - setValuesList := strings.Split(in.Set, ",") - for _, setValue := range setValuesList { - args = append(args, "--set", strings.TrimSpace(setValue)) + for _, setValue := range splitSetValues(in.Set) { + args = append(args, "--set", setValue) } } @@ -231,6 +230,40 @@ func handleHelmUpgradeRelease(ctx context.Context, request *sdkmcp.CallToolReque return mcp.TextResult(result) } +// splitSetValues splits a comma-separated list of key=value pairs into one +// item per --set flag. Commas inside a {a,b} list or escaped as \, belong to +// the value, as they do for helm's own --set parser. +func splitSetValues(set string) []string { + var values []string + var current strings.Builder + depth := 0 + escaped := false + flush := func() { + if value := strings.TrimSpace(current.String()); value != "" { + values = append(values, value) + } + current.Reset() + } + for _, r := range set { + switch { + case escaped: + escaped = false + case r == '\\': + escaped = true + case r == '{': + depth++ + case r == '}' && depth > 0: + depth-- + case r == ',' && depth == 0: + flush() + continue + } + current.WriteRune(r) + } + flush() + return values +} + type helmUninstallInput struct { Name string `json:"name" jsonschema:"The name of the release to uninstall"` Namespace string `json:"namespace" jsonschema:"The namespace of the release"` diff --git a/pkg/helm/helm_test.go b/pkg/helm/helm_test.go index 2c2923c..d50c028 100644 --- a/pkg/helm/helm_test.go +++ b/pkg/helm/helm_test.go @@ -268,6 +268,31 @@ REVISION: 2` assert.Equal(t, expectedArgs, callLog[0].Args) }) + t.Run("upgrade keeps commas inside set lists and escapes", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedArgs := []string{ + "upgrade", "myapp", "stable/myapp", + "--set", "args={--verbose,--debug}", + "--set", `annotation=a\,b`, + "--set", "replicas=2", + "--timeout", "30s", + } + mock.AddCommandString("helm", expectedArgs, "Upgraded", nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) + + result, _, err := handleHelmUpgradeRelease(ctx, &sdkmcp.CallToolRequest{}, helmUpgradeReleaseInput{ + Name: "myapp", + Chart: "stable/myapp", + Set: `args={--verbose,--debug}, annotation=a\,b, replicas=2`, + }) + + assert.NoError(t, err) + assert.False(t, result.IsError) + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, expectedArgs, callLog[0].Args) + }) + t.Run("missing required parameters for upgrade", func(t *testing.T) { mock := cmd.NewMockShellExecutor() ctx := cmd.WithShellExecutor(context.Background(), mock) @@ -440,3 +465,20 @@ func getResultText(result *sdkmcp.CallToolResult) string { } return "" } + +func TestSplitSetValues(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"replicas=5,image.tag=v1.2.0", []string{"replicas=5", "image.tag=v1.2.0"}}, + {" replicas=5 , image.tag=v1.2.0 ", []string{"replicas=5", "image.tag=v1.2.0"}}, + {"tolerations={a,b},replicas=1", []string{"tolerations={a,b}", "replicas=1"}}, + {"outer={a,{b,c}},x=1", []string{"outer={a,{b,c}}", "x=1"}}, + {`note=a\,b,x=1`, []string{`note=a\,b`, "x=1"}}, + {"a=1,,b=2,", []string{"a=1", "b=2"}}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, splitSetValues(tt.in), tt.in) + } +}