Skip to content

Commit 9ee2de6

Browse files
committed
feat: add helm history tool
Signed-off-by: iamsudip <[email protected]> Signed-off-by: iamsudip <[email protected]>
1 parent ebdeb6a commit 9ee2de6

File tree

4 files changed

+203
-2
lines changed

4 files changed

+203
-2
lines changed

README.md

Lines changed: 9 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.
@@ -249,6 +250,9 @@ In case multi-cluster support is enabled (default) and you have access to multip
249250
- `query` (`string`) **(required)** - query specifies services(s) or files from which to return logs (required). Example: "kubelet" to fetch kubelet logs, "/<log-file-name>" to fetch a specific log file from the node (e.g., "/var/log/kubelet.log" or "/var/log/kube-proxy.log")
250251
- `tailLines` (`integer`) - Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)
251252

253+
- **nodes_stats_summary** - Get detailed resource usage statistics from a Kubernetes node via the kubelet's Summary API. Provides comprehensive metrics including CPU, memory, filesystem, and network usage at the node, pod, and container levels. On systems with cgroup v2 and kernel 4.20+, also includes PSI (Pressure Stall Information) metrics that show resource pressure for CPU, memory, and I/O. See https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ for details on PSI metrics
254+
- `name` (`string`) **(required)** - Name of the node to get stats from
255+
252256
- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
253257
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
254258

@@ -334,6 +338,11 @@ In case multi-cluster support is enabled (default) and you have access to multip
334338
- `name` (`string`) **(required)** - Name of the Helm release to uninstall
335339
- `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided)
336340

341+
- **helm_history** - Retrieve the revision history for a given Helm release
342+
- `max` (`integer`) - Maximum number of revisions to retrieve (Optional, all revisions if not provided)
343+
- `name` (`string`) **(required)** - Name of the Helm release to retrieve history for
344+
- `namespace` (`string`) - Namespace of the Helm release (Optional, current namespace if not provided)
345+
337346
</details>
338347

339348

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
@@ -94,6 +94,35 @@ func initHelm() []api.ServerTool {
9494
OpenWorldHint: ptr.To(true),
9595
},
9696
}, Handler: helmUninstall},
97+
{Tool: api.Tool{
98+
Name: "helm_history",
99+
Description: "Retrieve the revision history for a given Helm release",
100+
InputSchema: &jsonschema.Schema{
101+
Type: "object",
102+
Properties: map[string]*jsonschema.Schema{
103+
"name": {
104+
Type: "string",
105+
Description: "Name of the Helm release to retrieve history for",
106+
},
107+
"namespace": {
108+
Type: "string",
109+
Description: "Namespace of the Helm release (Optional, current namespace if not provided)",
110+
},
111+
"max": {
112+
Type: "integer",
113+
Description: "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
114+
},
115+
},
116+
Required: []string{"name"},
117+
},
118+
Annotations: api.ToolAnnotations{
119+
Title: "Helm: History",
120+
ReadOnlyHint: ptr.To(true),
121+
DestructiveHint: ptr.To(false),
122+
IdempotentHint: ptr.To(true),
123+
OpenWorldHint: ptr.To(true),
124+
},
125+
}, Handler: helmHistory},
97126
}
98127
}
99128

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

0 commit comments

Comments
 (0)