Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions pkg/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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"`
Expand Down
42 changes: 42 additions & 0 deletions pkg/helm/helm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Loading