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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
- **Install** a Helm chart in the current or provided namespace.
- **List** Helm releases in all namespaces or in a specific namespace.
- **Uninstall** a Helm release in the current or provided namespace.
- **History** - View revision history for a Helm release.

Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.
It is a **Go-based native implementation** that interacts directly with the Kubernetes API server.
Expand Down Expand Up @@ -249,6 +250,9 @@ In case multi-cluster support is enabled (default) and you have access to multip
- `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")
- `tailLines` (`integer`) - Number of lines to retrieve from the end of the logs (Optional, 0 means all logs)

- **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
- `name` (`string`) **(required)** - Name of the node to get stats from

- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
- `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

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

- **helm_history** - Retrieve the revision history for a given Helm release
- `max` (`integer`) - Maximum number of revisions to retrieve (Optional, all revisions if not provided)
- `name` (`string`) **(required)** - Name of the Helm release to retrieve history for
- `namespace` (`string`) - Namespace of the Helm release (Optional, current namespace if not provided)

</details>


Expand Down
46 changes: 44 additions & 2 deletions pkg/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ package helm
import (
"context"
"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 {
Expand Down Expand Up @@ -104,6 +105,30 @@ func (h *Helm) Uninstall(name string, namespace string) (string, error) {
return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), nil
}

// History retrieves the revision history for a given Helm release
func (h *Helm) History(name string, namespace string, max int) (string, error) {
cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false)
if err != nil {
return "", err
}
history := action.NewHistory(cfg)
releases, err := history.Run(name)
if err != nil {
return "", err
}
if len(releases) == 0 {
return fmt.Sprintf("No history found for release %s", name), nil
}
if max > 0 && len(releases) > max {
Copy link
Contributor Author

@iamsudip iamsudip Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to do it manually here cause helm cli also does same, the history object has a field Max, but that is still not used in history.Run() method to limit the result. So we fetch all, limit on client side to mimic the same behaviour of cli.

releases = releases[len(releases)-max:]
}
ret, err := yaml.Marshal(simplifyHistory(releases...))
if err != nil {
return "", err
}
return string(ret), nil
}

func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) {
cfg := new(action.Configuration)
applicableNamespace := ""
Expand Down Expand Up @@ -140,3 +165,20 @@ func simplify(release ...*release.Release) []map[string]interface{} {
}
return ret
}

func simplifyHistory(releases ...*release.Release) []map[string]interface{} {
ret := make([]map[string]interface{}, len(releases))
for i, r := range releases {
ret[i] = map[string]interface{}{
"revision": r.Version,
"updated": r.Info.LastDeployed.Format(time.RFC1123Z),
"status": r.Info.Status.String(),
"chart": fmt.Sprintf("%s-%s", r.Chart.Metadata.Name, r.Chart.Metadata.Version),
"appVersion": r.Chart.Metadata.AppVersion,
}
if r.Info.Description != "" {
ret[i]["description"] = r.Info.Description
}
}
return ret
}
100 changes: 100 additions & 0 deletions pkg/mcp/helm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,106 @@ func (s *HelmSuite) TestHelmUninstallDenied() {
})
}

func (s *HelmSuite) TestHelmHistoryNoReleases() {
s.InitMcpClient()
s.Run("helm_history(name=non-existent-release) with no releases", func() {
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
"name": "non-existent-release",
})
s.Run("has error", func() {
s.Truef(toolResult.IsError, "call tool should fail for non-existent release")
s.Nilf(err, "call tool should not return error object")
})
s.Run("describes error", func() {
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)
})
})
}

func (s *HelmSuite) TestHelmHistory() {
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
// Create multiple revisions of a release
for i := 1; i <= 3; i++ {
_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.release-with-history.v" + string(rune('0'+i)),
Labels: map[string]string{"owner": "helm", "name": "release-with-history", "version": string(rune('0' + i))},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"release-with-history\"," +
"\"version\":" + string(rune('0'+i)) + "," +
"\"info\":{\"status\":\"superseded\",\"last_deployed\":\"2024-01-01T00:00:00Z\",\"description\":\"Upgrade complete\"}," +
"\"chart\":{\"metadata\":{\"name\":\"test-chart\",\"version\":\"1.0.0\",\"appVersion\":\"1.0.0\"}}" +
"}"))),
},
}, metav1.CreateOptions{})
s.Require().NoError(err)
}
s.InitMcpClient()
s.Run("helm_history(name=release-with-history) with multiple revisions", func() {
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
"name": "release-with-history",
})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns history", func() {
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("has 3 items", func() {
s.Lenf(decoded, 3, "invalid helm history count, expected 3, got %v", len(decoded))
})
s.Run("has valid revision numbers", func() {
for i, item := range decoded {
expectedRevision := float64(i + 1)
s.Equalf(expectedRevision, item["revision"], "invalid revision for item %d, expected %v, got %v", i, expectedRevision, item["revision"])
}
})
s.Run("has valid status", func() {
s.Equalf("superseded", decoded[0]["status"], "invalid status, expected superseded, got %v", decoded[0]["status"])
})
s.Run("has valid chart", func() {
s.Equalf("test-chart-1.0.0", decoded[0]["chart"], "invalid chart, expected test-chart-1.0.0, got %v", decoded[0]["chart"])
})
s.Run("has valid appVersion", func() {
s.Equalf("1.0.0", decoded[0]["appVersion"], "invalid appVersion, expected 1.0.0, got %v", decoded[0]["appVersion"])
})
s.Run("has valid description", func() {
s.Equalf("Upgrade complete", decoded[0]["description"], "invalid description, expected 'Upgrade complete', got %v", decoded[0]["description"])
})
})
})
s.Run("helm_history(name=release-with-history, max=2) with max limit", func() {
toolResult, err := s.CallTool("helm_history", map[string]interface{}{
"name": "release-with-history",
"max": 2,
})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
s.Run("returns limited history", func() {
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
s.Run("has 2 items", func() {
s.Lenf(decoded, 2, "invalid helm history count with max=2, expected 2, got %v", len(decoded))
})
s.Run("returns most recent revisions", func() {
s.Equalf(float64(2), decoded[0]["revision"], "expected revision 2, got %v", decoded[0]["revision"])
s.Equalf(float64(3), decoded[1]["revision"], "expected revision 3, got %v", decoded[1]["revision"])
})
})
})
}

func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{})
for _, secret := range secrets.Items {
Expand Down
39 changes: 39 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,45 @@
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: History",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Retrieve the revision history for a given Helm release",
"inputSchema": {
"type": "object",
"properties": {
"context": {
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
"enum": [
"extra-cluster",
"fake-context"
],
"type": "string"
},
"max": {
"description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
"type": "integer"
},
"name": {
"description": "Name of the Helm release to retrieve history for",
"type": "string"
},
"namespace": {
"description": "Namespace of the Helm release (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_history"
},
{
"annotations": {
"title": "Helm: Install",
Expand Down
35 changes: 35 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,41 @@
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: History",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Retrieve the revision history for a given Helm release",
"inputSchema": {
"type": "object",
"properties": {
"context": {
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
"type": "string"
},
"max": {
"description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
"type": "integer"
},
"name": {
"description": "Name of the Helm release to retrieve history for",
"type": "string"
},
"namespace": {
"description": "Namespace of the Helm release (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_history"
},
{
"annotations": {
"title": "Helm: Install",
Expand Down
31 changes: 31 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-openshift.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,37 @@
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: History",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Retrieve the revision history for a given Helm release",
"inputSchema": {
"type": "object",
"properties": {
"max": {
"description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
"type": "integer"
},
"name": {
"description": "Name of the Helm release to retrieve history for",
"type": "string"
},
"namespace": {
"description": "Namespace of the Helm release (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_history"
},
{
"annotations": {
"title": "Helm: Install",
Expand Down
31 changes: 31 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,37 @@
}
},
"name": "events_list"
},
{
"annotations": {
"title": "Helm: History",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Retrieve the revision history for a given Helm release",
"inputSchema": {
"type": "object",
"properties": {
"max": {
"description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)",
"type": "integer"
},
"name": {
"description": "Name of the Helm release to retrieve history for",
"type": "string"
},
"namespace": {
"description": "Namespace of the Helm release (Optional, current namespace if not provided)",
"type": "string"
}
},
"required": [
"name"
]
},
"name": "helm_history"
},
{
"annotations": {
Expand Down
Loading
Loading