Skip to content

Commit cedc581

Browse files
committed
feat: add helm history tool
Signed-off-by: iamsudip <iamsudip@programmer.net> Signed-off-by: iamsudip <sudip.maji@harness.io>
1 parent 6342379 commit cedc581

File tree

4 files changed

+200
-2
lines changed

4 files changed

+200
-2
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
3434
- **Install** a Helm chart in the current or provided namespace.
3535
- **List** Helm releases in all namespaces or in a specific namespace.
3636
- **Uninstall** a Helm release in the current or provided namespace.
37+
- **History** - View revision history for a Helm release.
3738

3839
Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.
3940
It is a **Go-based native implementation** that interacts directly with the Kubernetes API server.
@@ -341,6 +342,11 @@ In case multi-cluster support is enabled (default) and you have access to multip
341342
- `name` (`string`) **(required)** - Name of the Helm release to uninstall
342343
- `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided)
343344

345+
- **helm_history** - Retrieve the revision history for a given Helm release
346+
- `max` (`integer`) - Maximum number of revisions to retrieve (Optional, all revisions if not provided)
347+
- `name` (`string`) **(required)** - Name of the Helm release to retrieve history for
348+
- `namespace` (`string`) - Namespace of the Helm release (Optional, current namespace if not provided)
349+
344350
</details>
345351

346352

pkg/helm/helm.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ package helm
33
import (
44
"context"
55
"fmt"
6+
"log"
7+
"time"
8+
69
"helm.sh/helm/v3/pkg/action"
710
"helm.sh/helm/v3/pkg/chart/loader"
811
"helm.sh/helm/v3/pkg/cli"
912
"helm.sh/helm/v3/pkg/registry"
1013
"helm.sh/helm/v3/pkg/release"
1114
"k8s.io/cli-runtime/pkg/genericclioptions"
12-
"log"
1315
"sigs.k8s.io/yaml"
14-
"time"
1516
)
1617

1718
type Kubernetes interface {
@@ -104,6 +105,30 @@ func (h *Helm) Uninstall(name string, namespace string) (string, error) {
104105
return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), nil
105106
}
106107

108+
// History retrieves the revision history for a given Helm release
109+
func (h *Helm) History(name string, namespace string, max int) (string, error) {
110+
cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false)
111+
if err != nil {
112+
return "", err
113+
}
114+
history := action.NewHistory(cfg)
115+
releases, err := history.Run(name)
116+
if err != nil {
117+
return "", err
118+
}
119+
if len(releases) == 0 {
120+
return fmt.Sprintf("No history found for release %s", name), nil
121+
}
122+
if max > 0 && len(releases) > max {
123+
releases = releases[len(releases)-max:]
124+
}
125+
ret, err := yaml.Marshal(simplifyHistory(releases...))
126+
if err != nil {
127+
return "", err
128+
}
129+
return string(ret), nil
130+
}
131+
107132
func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) {
108133
cfg := new(action.Configuration)
109134
applicableNamespace := ""
@@ -140,3 +165,20 @@ func simplify(release ...*release.Release) []map[string]interface{} {
140165
}
141166
return ret
142167
}
168+
169+
func simplifyHistory(releases ...*release.Release) []map[string]interface{} {
170+
ret := make([]map[string]interface{}, len(releases))
171+
for i, r := range releases {
172+
ret[i] = map[string]interface{}{
173+
"revision": r.Version,
174+
"updated": r.Info.LastDeployed.Format(time.RFC1123Z),
175+
"status": r.Info.Status.String(),
176+
"chart": fmt.Sprintf("%s-%s", r.Chart.Metadata.Name, r.Chart.Metadata.Version),
177+
"appVersion": r.Chart.Metadata.AppVersion,
178+
}
179+
if r.Info.Description != "" {
180+
ret[i]["description"] = r.Info.Description
181+
}
182+
}
183+
return ret
184+
}

pkg/mcp/helm_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,106 @@ func (s *HelmSuite) TestHelmUninstallDenied() {
266266
})
267267
}
268268

269+
func (s *HelmSuite) TestHelmHistoryNoReleases() {
270+
s.InitMcpClient()
271+
s.Run("helm_history(name=non-existent-release) with no releases", func() {
272+
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
273+
"name": "non-existent-release",
274+
})
275+
s.Run("has error", func() {
276+
s.Truef(toolResult.IsError, "call tool should fail for non-existent release")
277+
s.Nilf(err, "call tool should not return error object")
278+
})
279+
s.Run("describes error", func() {
280+
s.Truef(strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, "failed to retrieve helm history"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
281+
})
282+
})
283+
}
284+
285+
func (s *HelmSuite) TestHelmHistory() {
286+
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
287+
// Create multiple revisions of a release
288+
for i := 1; i <= 3; i++ {
289+
_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
290+
ObjectMeta: metav1.ObjectMeta{
291+
Name: "sh.helm.release.v1.release-with-history.v" + string(rune('0'+i)),
292+
Labels: map[string]string{"owner": "helm", "name": "release-with-history", "version": string(rune('0' + i))},
293+
},
294+
Data: map[string][]byte{
295+
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
296+
"\"name\":\"release-with-history\"," +
297+
"\"version\":" + string(rune('0'+i)) + "," +
298+
"\"info\":{\"status\":\"superseded\",\"last_deployed\":\"2024-01-01T00:00:00Z\",\"description\":\"Upgrade complete\"}," +
299+
"\"chart\":{\"metadata\":{\"name\":\"test-chart\",\"version\":\"1.0.0\",\"appVersion\":\"1.0.0\"}}" +
300+
"}"))),
301+
},
302+
}, metav1.CreateOptions{})
303+
s.Require().NoError(err)
304+
}
305+
s.InitMcpClient()
306+
s.Run("helm_history(name=release-with-history) with multiple revisions", func() {
307+
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
308+
"name": "release-with-history",
309+
})
310+
s.Run("no error", func() {
311+
s.Nilf(err, "call tool failed %v", err)
312+
s.Falsef(toolResult.IsError, "call tool failed")
313+
})
314+
s.Run("returns history", func() {
315+
var decoded []map[string]interface{}
316+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
317+
s.Run("has yaml content", func() {
318+
s.Nilf(err, "invalid tool result content %v", err)
319+
})
320+
s.Run("has 3 items", func() {
321+
s.Lenf(decoded, 3, "invalid helm history count, expected 3, got %v", len(decoded))
322+
})
323+
s.Run("has valid revision numbers", func() {
324+
for i, item := range decoded {
325+
expectedRevision := float64(i + 1)
326+
s.Equalf(expectedRevision, item["revision"], "invalid revision for item %d, expected %v, got %v", i, expectedRevision, item["revision"])
327+
}
328+
})
329+
s.Run("has valid status", func() {
330+
s.Equalf("superseded", decoded[0]["status"], "invalid status, expected superseded, got %v", decoded[0]["status"])
331+
})
332+
s.Run("has valid chart", func() {
333+
s.Equalf("test-chart-1.0.0", decoded[0]["chart"], "invalid chart, expected test-chart-1.0.0, got %v", decoded[0]["chart"])
334+
})
335+
s.Run("has valid appVersion", func() {
336+
s.Equalf("1.0.0", decoded[0]["appVersion"], "invalid appVersion, expected 1.0.0, got %v", decoded[0]["appVersion"])
337+
})
338+
s.Run("has valid description", func() {
339+
s.Equalf("Upgrade complete", decoded[0]["description"], "invalid description, expected 'Upgrade complete', got %v", decoded[0]["description"])
340+
})
341+
})
342+
})
343+
s.Run("helm_history(name=release-with-history, max=2) with max limit", func() {
344+
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
345+
"name": "release-with-history",
346+
"max": 2,
347+
})
348+
s.Run("no error", func() {
349+
s.Nilf(err, "call tool failed %v", err)
350+
s.Falsef(toolResult.IsError, "call tool failed")
351+
})
352+
s.Run("returns limited history", func() {
353+
var decoded []map[string]interface{}
354+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
355+
s.Run("has yaml content", func() {
356+
s.Nilf(err, "invalid tool result content %v", err)
357+
})
358+
s.Run("has 2 items", func() {
359+
s.Lenf(decoded, 2, "invalid helm history count with max=2, expected 2, got %v", len(decoded))
360+
})
361+
s.Run("returns most recent revisions", func() {
362+
s.Equalf(float64(2), decoded[0]["revision"], "expected revision 2, got %v", decoded[0]["revision"])
363+
s.Equalf(float64(3), decoded[1]["revision"], "expected revision 3, got %v", decoded[1]["revision"])
364+
})
365+
})
366+
})
367+
}
368+
269369
func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
270370
secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{})
271371
for _, secret := range secrets.Items {

pkg/toolsets/helm/helm.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,35 @@ func initHelm() []api.ServerTool {
9191
OpenWorldHint: ptr.To(true),
9292
},
9393
}, Handler: helmUninstall},
94+
{Tool: api.Tool{
95+
Name: "helm_history",
96+
Description: "Retrieve the revision history for a given Helm release",
97+
InputSchema: &jsonschema.Schema{
98+
Type: "object",
99+
Properties: map[string]*jsonschema.Schema{
100+
"name": {
101+
Type: "string",
102+
Description: "Name of the Helm release to retrieve history for",
103+
},
104+
"namespace": {
105+
Type: "string",
106+
Description: "Namespace of the Helm release (Optional, current namespace if not provided)",
107+
},
108+
"max": {
109+
Type: "integer",
110+
Description: "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
111+
},
112+
},
113+
Required: []string{"name"},
114+
},
115+
Annotations: api.ToolAnnotations{
116+
Title: "Helm: History",
117+
ReadOnlyHint: ptr.To(true),
118+
DestructiveHint: ptr.To(false),
119+
IdempotentHint: ptr.To(true),
120+
OpenWorldHint: ptr.To(true),
121+
},
122+
}, Handler: helmHistory},
94123
}
95124
}
96125

@@ -151,3 +180,24 @@ func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
151180
}
152181
return api.NewToolCallResult(ret, err), nil
153182
}
183+
184+
func helmHistory(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
185+
var name string
186+
ok := false
187+
if name, ok = params.GetArguments()["name"].(string); !ok {
188+
return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history, missing argument name")), nil
189+
}
190+
namespace := ""
191+
if v, ok := params.GetArguments()["namespace"].(string); ok {
192+
namespace = v
193+
}
194+
max := 0
195+
if v, ok := params.GetArguments()["max"].(float64); ok {
196+
max = int(v)
197+
}
198+
ret, err := params.NewHelm().History(name, namespace, max)
199+
if err != nil {
200+
return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history for release '%s': %w", name, err)), nil
201+
}
202+
return api.NewToolCallResult(ret, err), nil
203+
}

0 commit comments

Comments
 (0)