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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/containers/kubernetes-mcp-server?sort=semver)](https://github.com/containers/kubernetes-mcp-server/releases/latest)
[![Build](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml/badge.svg)](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml)

[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/manusa/kubernetes-mcp-server)](https://archestra.ai/mcp-catalog/manusa__kubernetes-mcp-server)

[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools) | [🧑‍💻 Development](#development)

https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs 2/images/vibe-coding.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions npm 2/kubernetes-mcp-server-windows-amd64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "kubernetes-mcp-server-windows-amd64",
"version": "0.0.0",
"description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
"os": [
"win32"
],
"cpu": [
"x64"
]
}
11 changes: 11 additions & 0 deletions npm 2/kubernetes-mcp-server-windows-arm64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "kubernetes-mcp-server-windows-arm64",
"version": "0.0.0",
"description": "Model Context Protocol (MCP) server for Kubernetes and OpenShift",
"os": [
"win32"
],
"cpu": [
"arm64"
]
}
51 changes: 51 additions & 0 deletions pkg 2/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package config

import (
"os"

"github.com/BurntSushi/toml"
)

// StaticConfig is the configuration for the server.
// It allows to configure server specific settings and tools to be enabled or disabled.
type StaticConfig struct {
DeniedResources []GroupVersionKind `toml:"denied_resources"`

LogLevel int `toml:"log_level,omitempty"`
Port string `toml:"port,omitempty"`
SSEBaseURL string `toml:"sse_base_url,omitempty"`
KubeConfig string `toml:"kubeconfig,omitempty"`
ListOutput string `toml:"list_output,omitempty"`
// When true, expose only tools annotated with readOnlyHint=true
ReadOnly bool `toml:"read_only,omitempty"`
// When true, disable tools annotated with destructiveHint=true
DisableDestructive bool `toml:"disable_destructive,omitempty"`
EnabledTools []string `toml:"enabled_tools,omitempty"`
DisabledTools []string `toml:"disabled_tools,omitempty"`
RequireOAuth bool `toml:"require_oauth,omitempty"`
AuthorizationURL string `toml:"authorization_url,omitempty"`
JwksURL string `toml:"jwks_url,omitempty"`
CertificateAuthority string `toml:"certificate_authority,omitempty"`
ServerURL string `toml:"server_url,omitempty"`
}

type GroupVersionKind struct {
Group string `toml:"group"`
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
}

// ReadConfig reads the toml file and returns the StaticConfig.
func ReadConfig(configPath string) (*StaticConfig, error) {
configData, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}

var config *StaticConfig
err = toml.Unmarshal(configData, &config)
if err != nil {
return nil, err
}
return config, nil
}
156 changes: 156 additions & 0 deletions pkg 2/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestReadConfigMissingFile(t *testing.T) {
config, err := ReadConfig("non-existent-config.toml")
t.Run("returns error for missing file", func(t *testing.T) {
if err == nil {
t.Fatal("Expected error for missing file, got nil")
}
if config != nil {
t.Fatalf("Expected nil config for missing file, got %v", config)
}
})
}

func TestReadConfigInvalid(t *testing.T) {
invalidConfigPath := writeConfig(t, `
[[denied_resources]]
group = "apps"
version = "v1"
kind = "Deployment"
[[denied_resources]]
group = "rbac.authorization.k8s.io"
version = "v1"
kind = "Role
`)

config, err := ReadConfig(invalidConfigPath)
t.Run("returns error for invalid file", func(t *testing.T) {
if err == nil {
t.Fatal("Expected error for invalid file, got nil")
}
if config != nil {
t.Fatalf("Expected nil config for invalid file, got %v", config)
}
})
t.Run("error message contains toml error with line number", func(t *testing.T) {
expectedError := "toml: line 9"
if err != nil && !strings.HasPrefix(err.Error(), expectedError) {
t.Fatalf("Expected error message '%s' to contain line number, got %v", expectedError, err)
}
})
}

func TestReadConfigValid(t *testing.T) {
validConfigPath := writeConfig(t, `
log_level = 1
port = "9999"
sse_base_url = "https://example.com"
kubeconfig = "./path/to/config"
list_output = "yaml"
read_only = true
disable_destructive = true

denied_resources = [
{group = "apps", version = "v1", kind = "Deployment"},
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
]

enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
`)

config, err := ReadConfig(validConfigPath)
t.Run("reads and unmarshalls file", func(t *testing.T) {
if err != nil {
t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
}
if config == nil {
t.Fatal("ReadConfig returned a nil config for a valid file")
}
})
t.Run("denied resources are parsed correctly", func(t *testing.T) {
if len(config.DeniedResources) != 2 {
t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
}
if config.DeniedResources[0].Group != "apps" ||
config.DeniedResources[0].Version != "v1" ||
config.DeniedResources[0].Kind != "Deployment" {
t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
}
})
t.Run("log_level parsed correctly", func(t *testing.T) {
if config.LogLevel != 1 {
t.Fatalf("Unexpected log level: %v", config.LogLevel)
}
})
t.Run("port parsed correctly", func(t *testing.T) {
if config.Port != "9999" {
t.Fatalf("Unexpected port value: %v", config.Port)
}
})
t.Run("sse_base_url parsed correctly", func(t *testing.T) {
if config.SSEBaseURL != "https://example.com" {
t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
}
})
t.Run("kubeconfig parsed correctly", func(t *testing.T) {
if config.KubeConfig != "./path/to/config" {
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
}
})
t.Run("list_output parsed correctly", func(t *testing.T) {
if config.ListOutput != "yaml" {
t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
}
})
t.Run("read_only parsed correctly", func(t *testing.T) {
if !config.ReadOnly {
t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
}
})
t.Run("disable_destructive parsed correctly", func(t *testing.T) {
if !config.DisableDestructive {
t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
}
})
t.Run("enabled_tools parsed correctly", func(t *testing.T) {
if len(config.EnabledTools) != 8 {
t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)

}
for i, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
if config.EnabledTools[i] != tool {
t.Errorf("Expected enabled tool %d to be %s, got %s", i, tool, config.EnabledTools[i])
}
}
})
t.Run("disabled_tools parsed correctly", func(t *testing.T) {
if len(config.DisabledTools) != 5 {
t.Fatalf("Unexpected disabled tools: %v", config.DisabledTools)
}
for i, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
if config.DisabledTools[i] != tool {
t.Errorf("Expected disabled tool %d to be %s, got %s", i, tool, config.DisabledTools[i])
}
}
})
}

func writeConfig(t *testing.T, content string) string {
t.Helper()
tempDir := t.TempDir()
path := filepath.Join(tempDir, "config.toml")
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to write config file %s: %v", path, err)
}
return path
}
142 changes: 142 additions & 0 deletions pkg 2/helm/helm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package helm

import (
"context"
"fmt"
"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 {
genericclioptions.RESTClientGetter
NamespaceOrDefault(namespace string) string
}

type Helm struct {
kubernetes Kubernetes
}

// NewHelm creates a new Helm instance
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(h.kubernetes.NamespaceOrDefault(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.LocateChart(chart, cli.New())
if err != nil {
return "", err
}
chartLoaded, err := loader.Load(chartRequested)
if err != nil {
return "", err
}

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, err := h.newAction(namespace, allNamespaces)
if err != nil {
return "", err
}
list := action.NewList(cfg)
list.AllNamespaces = allNamespaces
releases, err := list.Run()
if err != nil {
return "", err
} else if len(releases) == 0 {
return "No Helm releases found", nil
}
ret, err := yaml.Marshal(simplify(releases...))
if err != nil {
return "", err
}
return string(ret), nil
}

func (h *Helm) Uninstall(name string, namespace string) (string, error) {
cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false)
if err != nil {
return "", err
}
uninstall := action.NewUninstall(cfg)
uninstall.IgnoreNotFound = true
uninstall.Wait = true
uninstall.Timeout = 5 * time.Minute
uninstalledRelease, err := uninstall.Run(name)
if uninstalledRelease == nil && err == nil {
return fmt.Sprintf("Release %s not found", name), nil
} else if err != nil {
return "", err
}
return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), 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
}
Loading