mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(helm): initial support for helm install
This commit is contained in:
32
README.md
32
README.md
@@ -29,6 +29,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
|
||||
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
|
||||
- **✅ Projects**: List OpenShift Projects.
|
||||
- **☸️ Helm**:
|
||||
- **Install** a Helm chart in the current or provided namespace.
|
||||
- **List** Helm releases in all namespaces or in a specific namespace.
|
||||
|
||||
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
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to retrieve the events from. If not provided, will list events from all namespaces
|
||||
|
||||
### `helm_install`
|
||||
|
||||
Install a Helm chart in the current or provided namespace with the provided name and chart
|
||||
|
||||
**Parameters:**
|
||||
- `chart` (`string`, required)
|
||||
- Name of the Helm chart to install
|
||||
- Can be a local path or a remote URL
|
||||
- Example: `./my-chart.tgz` or `https://example.com/my-chart.tgz`
|
||||
- `values` (`object`, optional)
|
||||
- Values to pass to the Helm chart
|
||||
- Example: `{"key": "value"}`
|
||||
- `name` (`string`, optional)
|
||||
- Name of the Helm release
|
||||
- Random name if not provided
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to install the Helm chart in
|
||||
- If not provided, will use the configured namespace
|
||||
|
||||
### `helm_list`
|
||||
|
||||
List all the Helm releases in the current or provided namespace (or in all namespaces if specified)
|
||||
|
||||
**Parameters:**
|
||||
- `namespace` (`string`, optional)
|
||||
- Namespace to list the Helm releases from
|
||||
- If not provided, will use the configured namespace
|
||||
- `all_namespaces` (`boolean`, optional)
|
||||
- If `true`, will list Helm releases from all namespaces
|
||||
- If `false`, will list Helm releases from the specified namespace
|
||||
|
||||
### `namespaces_list`
|
||||
|
||||
List all the Kubernetes namespaces in the current cluster
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 {
|
||||
@@ -18,22 +23,48 @@ type Helm struct {
|
||||
}
|
||||
|
||||
// NewHelm creates a new Helm instance
|
||||
func NewHelm(kubernetes Kubernetes, namespace string) *Helm {
|
||||
settings := cli.New()
|
||||
if namespace != "" {
|
||||
settings.SetNamespace(namespace)
|
||||
}
|
||||
func NewHelm(kubernetes Kubernetes) *Helm {
|
||||
return &Helm{kubernetes: kubernetes}
|
||||
}
|
||||
|
||||
func (h *Helm) Install(ctx context.Context, chart string, values map[string]interface{}, name string, namespace string) (string, error) {
|
||||
cfg, err := h.newAction(namespace, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
install := action.NewInstall(cfg)
|
||||
if name == "" {
|
||||
install.GenerateName = true
|
||||
install.ReleaseName, _, _ = install.NameAndChart([]string{chart})
|
||||
} else {
|
||||
install.ReleaseName = name
|
||||
}
|
||||
install.Namespace = h.kubernetes.NamespaceOrDefault(namespace)
|
||||
install.Wait = true
|
||||
install.Timeout = 5 * time.Minute
|
||||
install.DryRun = false
|
||||
|
||||
chartRequested, err := install.ChartPathOptions.LocateChart(chart, cli.New())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
chartLoaded, err := loader.Load(chartRequested)
|
||||
|
||||
installedRelease, err := install.RunWithContext(ctx, chartLoaded, values)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret, err := yaml.Marshal(simplify(installedRelease))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(ret), nil
|
||||
}
|
||||
|
||||
// List lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces.
|
||||
func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
|
||||
cfg := new(action.Configuration)
|
||||
applicableNamespace := ""
|
||||
if !allNamespaces {
|
||||
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
|
||||
}
|
||||
if err := cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf); err != nil {
|
||||
cfg, err := h.newAction(namespace, allNamespaces)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
list := action.NewList(cfg)
|
||||
@@ -44,9 +75,46 @@ func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
|
||||
} else if len(releases) == 0 {
|
||||
return "No Helm releases found", nil
|
||||
}
|
||||
ret, err := yaml.Marshal(releases)
|
||||
ret, err := yaml.Marshal(simplify(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 := ""
|
||||
if !allNamespaces {
|
||||
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
|
||||
}
|
||||
registryClient, err := registry.NewClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.RegistryClient = registryClient
|
||||
return cfg, cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf)
|
||||
}
|
||||
|
||||
func simplify(release ...*release.Release) []map[string]interface{} {
|
||||
ret := make([]map[string]interface{}, len(release))
|
||||
for i, r := range release {
|
||||
ret[i] = map[string]interface{}{
|
||||
"name": r.Name,
|
||||
"namespace": r.Namespace,
|
||||
"revision": r.Version,
|
||||
}
|
||||
if r.Chart != nil {
|
||||
ret[i]["chart"] = r.Chart.Metadata.Name
|
||||
ret[i]["chartVersion"] = r.Chart.Metadata.Version
|
||||
ret[i]["appVersion"] = r.Chart.Metadata.AppVersion
|
||||
}
|
||||
if r.Info != nil {
|
||||
ret[i]["status"] = r.Info.Status.String()
|
||||
if !r.Info.LastDeployed.IsZero() {
|
||||
ret[i]["lastDeployed"] = r.Info.LastDeployed.Format(time.RFC1123Z)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
|
||||
return nil, err
|
||||
}
|
||||
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
|
||||
k8s.Helm = helm.NewHelm(k8s, "TODO")
|
||||
k8s.Helm = helm.NewHelm(k8s)
|
||||
return k8s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,45 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) initHelm() []server.ServerTool {
|
||||
rets := make([]server.ServerTool, 0)
|
||||
rets = append(rets, server.ServerTool{
|
||||
Tool: mcp.NewTool("helm_list",
|
||||
mcp.WithDescription("List all of the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
|
||||
return []server.ServerTool{
|
||||
{mcp.NewTool("helm_install",
|
||||
mcp.WithDescription("Install a Helm chart in the current or provided namespace"),
|
||||
mcp.WithString("chart", mcp.Description("Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)"), mcp.Required()),
|
||||
mcp.WithObject("values", mcp.Description("Values to pass to the Helm chart (Optional)")),
|
||||
mcp.WithString("name", mcp.Description("Name of the Helm release (Optional, random name if not provided)")),
|
||||
mcp.WithString("namespace", mcp.Description("Namespace to install the Helm chart in (Optional, current namespace if not provided)")),
|
||||
), s.helmInstall},
|
||||
{mcp.NewTool("helm_list",
|
||||
mcp.WithDescription("List all the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
|
||||
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
|
||||
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
|
||||
),
|
||||
Handler: s.helmList,
|
||||
})
|
||||
return rets
|
||||
), s.helmList},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var chart string
|
||||
ok := false
|
||||
if chart, ok = ctr.Params.Arguments["chart"].(string); !ok {
|
||||
return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
|
||||
}
|
||||
values := map[string]interface{}{}
|
||||
if v, ok := ctr.Params.Arguments["values"].(map[string]interface{}); ok {
|
||||
values = v
|
||||
}
|
||||
name := ""
|
||||
if v, ok := ctr.Params.Arguments["name"].(string); ok {
|
||||
name = v
|
||||
}
|
||||
namespace := ""
|
||||
if v, ok := ctr.Params.Arguments["namespace"].(string); ok {
|
||||
namespace = v
|
||||
}
|
||||
ret, err := s.k.Helm.Install(ctx, chart, values, name, namespace)
|
||||
if err != nil {
|
||||
return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
|
||||
}
|
||||
return NewTextResult(ret, err), nil
|
||||
}
|
||||
|
||||
func (s *Server) helmList(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
|
||||
@@ -5,14 +5,65 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sigs.k8s.io/yaml"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHelmInstall(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
|
||||
toolResult, err := c.callTool("helm_install", map[string]interface{}{
|
||||
"chart": chartPath,
|
||||
})
|
||||
t.Run("helm_install with local chart and no release name, returns installed chart", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("call tool failed %v", err)
|
||||
}
|
||||
if toolResult.IsError {
|
||||
t.Fatalf("call tool failed")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid tool result content %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-") {
|
||||
t.Fatalf("invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
|
||||
}
|
||||
if decoded[0]["namespace"] != "default" {
|
||||
t.Fatalf("invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
|
||||
}
|
||||
if decoded[0]["chart"] != "no-op" {
|
||||
t.Fatalf("invalid helm install name, expected release name, got empty")
|
||||
}
|
||||
if decoded[0]["chartVersion"] != "1.33.7" {
|
||||
t.Fatalf("invalid helm install version, expected 1.33.7, got empty")
|
||||
}
|
||||
if decoded[0]["status"] != "deployed" {
|
||||
t.Fatalf("invalid helm install status, expected deployed, got %v", decoded[0]["status"])
|
||||
}
|
||||
if decoded[0]["revision"] != float64(1) {
|
||||
t.Fatalf("invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestHelmList(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
kc := c.newKubernetesClient()
|
||||
secrets, err := kc.CoreV1().Secrets("default").List(c.ctx, metav1.ListOptions{})
|
||||
for _, secret := range secrets.Items {
|
||||
if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") {
|
||||
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, secret.Name, metav1.DeleteOptions{})
|
||||
}
|
||||
}
|
||||
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, "release-to-list", metav1.DeleteOptions{})
|
||||
toolResult, err := c.callTool("helm_list", map[string]interface{}{})
|
||||
t.Run("helm_list with no releases, returns not found", func(t *testing.T) {
|
||||
@@ -57,8 +108,8 @@ func TestHelmList(t *testing.T) {
|
||||
if decoded[0]["name"] != "release-to-list" {
|
||||
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
|
||||
}
|
||||
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
|
||||
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
|
||||
if decoded[0]["status"] != "deployed" {
|
||||
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
|
||||
}
|
||||
})
|
||||
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
|
||||
@@ -92,8 +143,8 @@ func TestHelmList(t *testing.T) {
|
||||
if decoded[0]["name"] != "release-to-list" {
|
||||
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
|
||||
}
|
||||
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
|
||||
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
|
||||
if decoded[0]["status"] != "deployed" {
|
||||
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["status"])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ func TestTools(t *testing.T) {
|
||||
expectedNames := []string{
|
||||
"configuration_view",
|
||||
"events_list",
|
||||
"helm_install",
|
||||
"helm_list",
|
||||
"namespaces_list",
|
||||
"pods_list",
|
||||
|
||||
3
pkg/mcp/testdata/helm-chart-no-op/Chart.yaml
vendored
Normal file
3
pkg/mcp/testdata/helm-chart-no-op/Chart.yaml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
apiVersion: v1
|
||||
name: no-op
|
||||
version: 1.33.7
|
||||
Reference in New Issue
Block a user