Skip to content

Commit 22669e7

Browse files
committed
feat(helm): initial support for helm install
1 parent 0284cdc commit 22669e7

File tree

7 files changed

+209
-25
lines changed

7 files changed

+209
-25
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
2929
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
3030
- **✅ Projects**: List OpenShift Projects.
3131
- **☸️ Helm**:
32+
- **Install** a Helm chart in the current or provided namespace.
3233
- **List** Helm releases in all namespaces or in a specific namespace.
3334

3435
Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools.
@@ -163,6 +164,37 @@ List all the Kubernetes events in the current cluster from all namespaces
163164
- `namespace` (`string`, optional)
164165
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
165166

167+
### `helm_install`
168+
169+
Install a Helm chart in the current or provided namespace with the provided name and chart
170+
171+
**Parameters:**
172+
- `chart` (`string`, required)
173+
- Name of the Helm chart to install
174+
- Can be a local path or a remote URL
175+
- Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz`
176+
- `values` (`object`, optional)
177+
- Values to pass to the Helm chart
178+
- Example: `{"key": "value"}`
179+
- `name` (`string`, optional)
180+
- Name of the Helm release
181+
- Random name if not provided
182+
- `namespace` (`string`, optional)
183+
- Namespace to install the Helm chart in
184+
- If not provided, will use the configured namespace
185+
186+
### `helm_list`
187+
188+
List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
189+
190+
**Parameters:**
191+
- `namespace` (`string`, optional)
192+
- Namespace to list the Helm releases from
193+
- If not provided, will use the configured namespace
194+
- `all_namespaces` (`boolean`, optional)
195+
- If `true`, will list Helm releases from all namespaces
196+
- If `false`, will list Helm releases from the specified namespace
197+
166198
### `namespaces_list`
167199

168200
List all the Kubernetes namespaces in the current cluster

pkg/helm/helm.go

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package helm
22

33
import (
4+
"context"
45
"helm.sh/helm/v3/pkg/action"
6+
"helm.sh/helm/v3/pkg/chart/loader"
57
"helm.sh/helm/v3/pkg/cli"
8+
"helm.sh/helm/v3/pkg/registry"
9+
"helm.sh/helm/v3/pkg/release"
610
"k8s.io/cli-runtime/pkg/genericclioptions"
711
"log"
812
"sigs.k8s.io/yaml"
13+
"time"
914
)
1015

1116
type Kubernetes interface {
@@ -18,22 +23,48 @@ type Helm struct {
1823
}
1924

2025
// NewHelm creates a new Helm instance
21-
func NewHelm(kubernetes Kubernetes, namespace string) *Helm {
22-
settings := cli.New()
23-
if namespace != "" {
24-
settings.SetNamespace(namespace)
25-
}
26+
func NewHelm(kubernetes Kubernetes) *Helm {
2627
return &Helm{kubernetes: kubernetes}
2728
}
2829

30+
func (h *Helm) Install(ctx context.Context, chart string, values map[string]interface{}, name string, namespace string) (string, error) {
31+
cfg, err := h.newAction(namespace, false)
32+
if err != nil {
33+
return "", err
34+
}
35+
install := action.NewInstall(cfg)
36+
if name == "" {
37+
install.GenerateName = true
38+
install.ReleaseName, _, _ = install.NameAndChart([]string{chart})
39+
} else {
40+
install.ReleaseName = name
41+
}
42+
install.Namespace = h.kubernetes.NamespaceOrDefault(namespace)
43+
install.Wait = true
44+
install.Timeout = 5 * time.Minute
45+
install.DryRun = false
46+
47+
chartRequested, err := install.ChartPathOptions.LocateChart(chart, cli.New())
48+
if err != nil {
49+
return "", err
50+
}
51+
chartLoaded, err := loader.Load(chartRequested)
52+
53+
installedRelease, err := install.RunWithContext(ctx, chartLoaded, values)
54+
if err != nil {
55+
return "", err
56+
}
57+
ret, err := yaml.Marshal(simplify(installedRelease))
58+
if err != nil {
59+
return "", err
60+
}
61+
return string(ret), nil
62+
}
63+
2964
// List lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces.
3065
func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
31-
cfg := new(action.Configuration)
32-
applicableNamespace := ""
33-
if !allNamespaces {
34-
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
35-
}
36-
if err := cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf); err != nil {
66+
cfg, err := h.newAction(namespace, allNamespaces)
67+
if err != nil {
3768
return "", err
3869
}
3970
list := action.NewList(cfg)
@@ -44,9 +75,46 @@ func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
4475
} else if len(releases) == 0 {
4576
return "No Helm releases found", nil
4677
}
47-
ret, err := yaml.Marshal(releases)
78+
ret, err := yaml.Marshal(simplify(releases...))
4879
if err != nil {
4980
return "", err
5081
}
5182
return string(ret), nil
5283
}
84+
85+
func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) {
86+
cfg := new(action.Configuration)
87+
applicableNamespace := ""
88+
if !allNamespaces {
89+
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
90+
}
91+
registryClient, err := registry.NewClient()
92+
if err != nil {
93+
return nil, err
94+
}
95+
cfg.RegistryClient = registryClient
96+
return cfg, cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf)
97+
}
98+
99+
func simplify(release ...*release.Release) []map[string]interface{} {
100+
ret := make([]map[string]interface{}, len(release))
101+
for i, r := range release {
102+
ret[i] = map[string]interface{}{
103+
"name": r.Name,
104+
"namespace": r.Namespace,
105+
"revision": r.Version,
106+
}
107+
if r.Chart != nil {
108+
ret[i]["chart"] = r.Chart.Metadata.Name
109+
ret[i]["chartVersion"] = r.Chart.Metadata.Version
110+
ret[i]["appVersion"] = r.Chart.Metadata.AppVersion
111+
}
112+
if r.Info != nil {
113+
ret[i]["status"] = r.Info.Status.String()
114+
if !r.Info.LastDeployed.IsZero() {
115+
ret[i]["lastDeployed"] = r.Info.LastDeployed.Format(time.RFC1123Z)
116+
}
117+
}
118+
}
119+
return ret
120+
}

pkg/kubernetes/kubernetes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
6161
return nil, err
6262
}
6363
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
64-
k8s.Helm = helm.NewHelm(k8s, "TODO")
64+
k8s.Helm = helm.NewHelm(k8s)
6565
return k8s, nil
6666
}
6767

pkg/mcp/helm.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,45 @@ import (
88
)
99

1010
func (s *Server) initHelm() []server.ServerTool {
11-
rets := make([]server.ServerTool, 0)
12-
rets = append(rets, server.ServerTool{
13-
Tool: mcp.NewTool("helm_list",
14-
mcp.WithDescription("List all of the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
11+
return []server.ServerTool{
12+
{mcp.NewTool("helm_install",
13+
mcp.WithDescription("Install a Helm chart in the current or provided namespace"),
14+
mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()),
15+
mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")),
16+
mcp.WithString("name", mcp.Description("Name of the Helm release (Optional, random name if not provided)")),
17+
mcp.WithString("namespace", mcp.Description("Namespace to install the Helm chart in (Optional, current namespace if not provided)")),
18+
), s.helmInstall},
19+
{mcp.NewTool("helm_list",
20+
mcp.WithDescription("List all the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
1521
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
1622
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
17-
),
18-
Handler: s.helmList,
19-
})
20-
return rets
23+
), s.helmList},
24+
}
25+
}
26+
27+
func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
28+
var chart string
29+
ok := false
30+
if chart, ok = ctr.Params.Arguments["chart"].(string); !ok {
31+
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
32+
}
33+
values := map[string]interface{}{}
34+
if v, ok := ctr.Params.Arguments["values"].(map[string]interface{}); ok {
35+
values = v
36+
}
37+
name := ""
38+
if v, ok := ctr.Params.Arguments["name"].(string); ok {
39+
name = v
40+
}
41+
namespace := ""
42+
if v, ok := ctr.Params.Arguments["namespace"].(string); ok {
43+
namespace = v
44+
}
45+
ret, err := s.k.Helm.Install(ctx, chart, values, name, namespace)
46+
if err != nil {
47+
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
48+
}
49+
return NewTextResult(ret, err), nil
2150
}
2251

2352
func (s *Server) helmList(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {

pkg/mcp/helm_test.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,65 @@ import (
55
"github.com/mark3labs/mcp-go/mcp"
66
corev1 "k8s.io/api/core/v1"
77
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"path/filepath"
9+
"runtime"
810
"sigs.k8s.io/yaml"
11+
"strings"
912
"testing"
1013
)
1114

15+
func TestHelmInstall(t *testing.T) {
16+
testCase(t, func(c *mcpContext) {
17+
c.withEnvTest()
18+
_, file, _, _ := runtime.Caller(0)
19+
chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
20+
toolResult, err := c.callTool("helm_install", map[string]interface{}{
21+
"chart": chartPath,
22+
})
23+
t.Run("helm_install with local chart and no release name, returns installed chart", func(t *testing.T) {
24+
if err != nil {
25+
t.Fatalf("call tool failed %v", err)
26+
}
27+
if toolResult.IsError {
28+
t.Fatalf("call tool failed")
29+
}
30+
var decoded []map[string]interface{}
31+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
32+
if err != nil {
33+
t.Fatalf("invalid tool result content %v", err)
34+
}
35+
if !strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-") {
36+
t.Fatalf("invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
37+
}
38+
if decoded[0]["namespace"] != "default" {
39+
t.Fatalf("invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
40+
}
41+
if decoded[0]["chart"] != "no-op" {
42+
t.Fatalf("invalid helm install name, expected release name, got empty")
43+
}
44+
if decoded[0]["chartVersion"] != "1.33.7" {
45+
t.Fatalf("invalid helm install version, expected 1.33.7, got empty")
46+
}
47+
if decoded[0]["status"] != "deployed" {
48+
t.Fatalf("invalid helm install status, expected deployed, got %v", decoded[0]["status"])
49+
}
50+
if decoded[0]["revision"] != float64(1) {
51+
t.Fatalf("invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
52+
}
53+
})
54+
})
55+
}
56+
1257
func TestHelmList(t *testing.T) {
1358
testCase(t, func(c *mcpContext) {
1459
c.withEnvTest()
1560
kc := c.newKubernetesClient()
61+
secrets, err := kc.CoreV1().Secrets("default").List(c.ctx, metav1.ListOptions{})
62+
for _, secret := range secrets.Items {
63+
if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") {
64+
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, secret.Name, metav1.DeleteOptions{})
65+
}
66+
}
1667
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, "release-to-list", metav1.DeleteOptions{})
1768
toolResult, err := c.callTool("helm_list", map[string]interface{}{})
1869
t.Run("helm_list with no releases, returns not found", func(t *testing.T) {
@@ -57,8 +108,8 @@ func TestHelmList(t *testing.T) {
57108
if decoded[0]["name"] != "release-to-list" {
58109
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
59110
}
60-
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
61-
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
111+
if decoded[0]["status"] != "deployed" {
112+
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
62113
}
63114
})
64115
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
@@ -92,8 +143,8 @@ func TestHelmList(t *testing.T) {
92143
if decoded[0]["name"] != "release-to-list" {
93144
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
94145
}
95-
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
96-
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
146+
if decoded[0]["status"] != "deployed" {
147+
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
97148
}
98149
})
99150
})

pkg/mcp/mcp_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func TestTools(t *testing.T) {
5454
expectedNames := []string{
5555
"configuration_view",
5656
"events_list",
57+
"helm_install",
5758
"helm_list",
5859
"namespaces_list",
5960
"pods_list",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
apiVersion: v1
2+
name: no-op
3+
version: 1.33.7

0 commit comments

Comments
 (0)