From 9ee2de6342f986e7cbdf6bfcb40304f84431a1a5 Mon Sep 17 00:00:00 2001 From: iamsudip Date: Thu, 30 Oct 2025 16:54:58 +0530 Subject: [PATCH 1/2] feat: add helm history tool Signed-off-by: iamsudip Signed-off-by: iamsudip --- README.md | 9 ++++ pkg/helm/helm.go | 46 +++++++++++++++++- pkg/mcp/helm_test.go | 100 ++++++++++++++++++++++++++++++++++++++ pkg/toolsets/helm/helm.go | 50 +++++++++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a81daea1..26947351 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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, "/" 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 @@ -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) + diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 186b50df..7f6cb261 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -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 { @@ -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 { + 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 := "" @@ -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 +} diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index 5d52c79c..ba8d9443 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -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 { diff --git a/pkg/toolsets/helm/helm.go b/pkg/toolsets/helm/helm.go index 0352cf60..17aabf06 100644 --- a/pkg/toolsets/helm/helm.go +++ b/pkg/toolsets/helm/helm.go @@ -94,6 +94,35 @@ func initHelm() []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: helmUninstall}, + {Tool: api.Tool{ + Name: "helm_history", + Description: "Retrieve the revision history for a given Helm release", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the Helm release to retrieve history for", + }, + "namespace": { + Type: "string", + Description: "Namespace of the Helm release (Optional, current namespace if not provided)", + }, + "max": { + Type: "integer", + Description: "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Helm: History", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: helmHistory}, } } @@ -154,3 +183,24 @@ func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } return api.NewToolCallResult(ret, err), nil } + +func helmHistory(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + var name string + ok := false + if name, ok = params.GetArguments()["name"].(string); !ok { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history, missing argument name")), nil + } + namespace := "" + if v, ok := params.GetArguments()["namespace"].(string); ok { + namespace = v + } + max := 0 + if v, ok := params.GetArguments()["max"].(float64); ok { + max = int(v) + } + ret, err := params.NewHelm().History(name, namespace, max) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history for release '%s': %w", name, err)), nil + } + return api.NewToolCallResult(ret, err), nil +} From 09afb5aad8e94e4500e2ee44ecad1e2323ef882e Mon Sep 17 00:00:00 2001 From: iamsudip Date: Thu, 30 Oct 2025 17:18:53 +0530 Subject: [PATCH 2/2] update testdata Signed-off-by: iamsudip --- ...toolsets-full-tools-multicluster-enum.json | 39 +++++++++++++++++++ .../toolsets-full-tools-multicluster.json | 35 +++++++++++++++++ .../toolsets-full-tools-openshift.json | 31 +++++++++++++++ pkg/mcp/testdata/toolsets-full-tools.json | 31 +++++++++++++++ pkg/mcp/testdata/toolsets-helm-tools.json | 31 +++++++++++++++ 5 files changed, 167 insertions(+) diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 1551b4c2..d4b488c8 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -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", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index 6e85e401..5cd1c54d 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -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", diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index fb24138e..aa323a45 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -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", diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 5a4b5112..04288659 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -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": { diff --git a/pkg/mcp/testdata/toolsets-helm-tools.json b/pkg/mcp/testdata/toolsets-helm-tools.json index c57dfc27..21a8cd75 100644 --- a/pkg/mcp/testdata/toolsets-helm-tools.json +++ b/pkg/mcp/testdata/toolsets-helm-tools.json @@ -1,4 +1,35 @@ [ + { + "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",