feat(kubernetes): added --kubeconfig flag option

This commit is contained in:
Marc Nuri
2025-04-19 10:01:41 +02:00
parent 5d3c7f39cf
commit fa5bb81fe5
11 changed files with 242 additions and 79 deletions

View File

@@ -130,10 +130,11 @@ npx kubernetes-mcp-server@latest --help
### Configuration Options
| Option | Description |
|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
| `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
| Option | Description |
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
| `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
## 🛠️ Tools <a id="tools"></a>

View File

@@ -45,7 +45,9 @@ Kubernetes Model Context Protocol (MCP) server
fmt.Println(version.Version)
return
}
mcpServer, err := mcp.NewSever()
mcpServer, err := mcp.NewSever(mcp.Configuration{
Kubeconfig: viper.GetString("kubeconfig"),
})
if err != nil {
fmt.Printf("Failed to initialize MCP server: %v\n", err)
os.Exit(1)
@@ -73,6 +75,7 @@ func init() {
rootCmd.Flags().IntP("log-level", "", 0, "Set the log level (from 0 to 9)")
rootCmd.Flags().IntP("sse-port", "", 0, "Start a SSE server on the specified port")
rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication")
_ = viper.BindPFlags(rootCmd.Flags())
}

View File

@@ -1,29 +1,75 @@
package kubernetes
import (
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"k8s.io/client-go/tools/clientcmd/api/latest"
)
func ConfigurationView(minify bool) (string, error) {
// InClusterConfig is a variable that holds the function to get the in-cluster config
// Exposed for testing
var InClusterConfig = func() (*rest.Config, error) {
// TODO use kubernetes.default.svc instead of resolved server
// Currently running into: `http: server gave HTTP response to HTTPS client`
inClusterConfig, err := rest.InClusterConfig()
if inClusterConfig != nil {
inClusterConfig.Host = "https://kubernetes.default.svc"
}
return inClusterConfig, err
}
// resolveKubernetesConfigurations resolves the required kubernetes configurations and sets them in the Kubernetes struct
func resolveKubernetesConfigurations(kubernetes *Kubernetes) error {
// Always set clientCmdConfig
pathOptions := clientcmd.NewDefaultPathOptions()
if kubernetes.Kubeconfig != "" {
pathOptions.LoadingRules.ExplicitPath = kubernetes.Kubeconfig
}
kubernetes.clientCmdConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
var err error
if kubernetes.IsInCluster() {
kubernetes.cfg, err = InClusterConfig()
if err == nil && kubernetes.cfg != nil {
return nil
}
}
// Out of cluster
kubernetes.cfg, err = kubernetes.clientCmdConfig.ClientConfig()
if kubernetes.cfg != nil && kubernetes.cfg.UserAgent == "" {
kubernetes.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
return err
}
func (k *Kubernetes) IsInCluster() bool {
if k.Kubeconfig != "" {
return false
}
cfg, err := InClusterConfig()
return err == nil && cfg != nil
}
func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
var cfg clientcmdapi.Config
var err error
inClusterConfig, err := InClusterConfig()
if err == nil && inClusterConfig != nil {
if k.IsInCluster() {
cfg = *clientcmdapi.NewConfig()
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
Server: inClusterConfig.Host,
InsecureSkipTLSVerify: inClusterConfig.Insecure,
Server: k.cfg.Host,
InsecureSkipTLSVerify: k.cfg.Insecure,
}
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
Token: inClusterConfig.BearerToken,
Token: k.cfg.BearerToken,
}
cfg.Contexts["context"] = &clientcmdapi.Context{
Cluster: "cluster",
AuthInfo: "user",
}
cfg.CurrentContext = "context"
} else if cfg, err = resolveConfig().RawConfig(); err != nil {
} else if cfg, err = k.clientCmdConfig.RawConfig(); err != nil {
return "", err
}
if minify {

View File

@@ -0,0 +1,134 @@
package kubernetes
import (
"errors"
"k8s.io/client-go/rest"
"os"
"path"
"strings"
"testing"
)
func TestKubernetes_IsInCluster(t *testing.T) {
t.Run("with explicit kubeconfig", func(t *testing.T) {
k := Kubernetes{
Kubeconfig: "kubeconfig",
}
if k.IsInCluster() {
t.Errorf("expected not in cluster, got in cluster")
}
})
t.Run("with empty kubeconfig and in cluster", func(t *testing.T) {
originalFunction := InClusterConfig
InClusterConfig = func() (*rest.Config, error) {
return &rest.Config{}, nil
}
defer func() {
InClusterConfig = originalFunction
}()
k := Kubernetes{
Kubeconfig: "",
}
if !k.IsInCluster() {
t.Errorf("expected in cluster, got not in cluster")
}
})
t.Run("with empty kubeconfig and not in cluster (empty)", func(t *testing.T) {
originalFunction := InClusterConfig
InClusterConfig = func() (*rest.Config, error) {
return nil, nil
}
defer func() {
InClusterConfig = originalFunction
}()
k := Kubernetes{
Kubeconfig: "",
}
if k.IsInCluster() {
t.Errorf("expected not in cluster, got in cluster")
}
})
t.Run("with empty kubeconfig and not in cluster (error)", func(t *testing.T) {
originalFunction := InClusterConfig
InClusterConfig = func() (*rest.Config, error) {
return nil, errors.New("error")
}
defer func() {
InClusterConfig = originalFunction
}()
k := Kubernetes{
Kubeconfig: "",
}
if k.IsInCluster() {
t.Errorf("expected not in cluster, got in cluster")
}
})
}
func TestKubernetes_ResolveKubernetesConfigurations_Explicit(t *testing.T) {
t.Run("with missing file", func(t *testing.T) {
tempDir := t.TempDir()
k := Kubernetes{Kubeconfig: path.Join(tempDir, "config")}
err := resolveKubernetesConfigurations(&k)
if err == nil {
t.Errorf("expected error, got nil")
}
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("expected file not found error, got %v", err)
}
if !strings.HasSuffix(err.Error(), ": no such file or directory") {
t.Errorf("expected file not found error, got %v", err)
}
})
t.Run("with empty file", func(t *testing.T) {
tempDir := t.TempDir()
kubeconfigPath := path.Join(tempDir, "config")
if err := os.WriteFile(kubeconfigPath, []byte(""), 0644); err != nil {
t.Fatalf("failed to create kubeconfig file: %v", err)
}
k := Kubernetes{Kubeconfig: kubeconfigPath}
err := resolveKubernetesConfigurations(&k)
if err == nil {
t.Errorf("expected error, got nil")
}
if !strings.Contains(err.Error(), "no configuration has been provided") {
t.Errorf("expected no kubeconfig error, got %v", err)
}
})
t.Run("with valid file", func(t *testing.T) {
tempDir := t.TempDir()
kubeconfigPath := path.Join(tempDir, "config")
kubeconfigContent := `
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://example.com
name: example-cluster
contexts:
- context:
cluster: example-cluster
user: example-user
name: example-context
current-context: example-context
users:
- name: example-user
user:
token: example-token
`
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644); err != nil {
t.Fatalf("failed to create kubeconfig file: %v", err)
}
k := Kubernetes{Kubeconfig: kubeconfigPath}
err := resolveKubernetesConfigurations(&k)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if k.cfg == nil {
t.Errorf("expected non-nil config, got nil")
}
if k.cfg.Host != "https://example.com" {
t.Errorf("expected host https://example.com, got %s", k.cfg.Host)
}
})
}

View File

@@ -12,27 +12,16 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"sigs.k8s.io/yaml"
)
// InClusterConfig is a variable that holds the function to get the in-cluster config
// Exposed for testing
var InClusterConfig = func() (*rest.Config, error) {
// TODO use kubernetes.default.svc instead of resolved server
// Currently running into: `http: server gave HTTP response to HTTPS client`
inClusterConfig, err := rest.InClusterConfig()
if inClusterConfig != nil {
inClusterConfig.Host = "https://kubernetes.default.svc"
}
return inClusterConfig, err
}
type CloseWatchKubeConfig func() error
type Kubernetes struct {
// Kubeconfig path override
Kubeconfig string
cfg *rest.Config
kubeConfigFiles []string
clientCmdConfig clientcmd.ClientConfig
CloseWatchKubeConfig CloseWatchKubeConfig
scheme *runtime.Scheme
parameterCodec runtime.ParameterCodec
@@ -42,14 +31,14 @@ type Kubernetes struct {
dynamicClient *dynamic.DynamicClient
}
func NewKubernetes() (*Kubernetes, error) {
k8s := &Kubernetes{}
var err error
k8s.cfg, err = resolveClientConfig()
if err != nil {
func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
k8s := &Kubernetes{
Kubeconfig: kubeconfig,
}
if err := resolveKubernetesConfigurations(k8s); err != nil {
return nil, err
}
k8s.kubeConfigFiles = resolveConfig().ConfigAccess().GetLoadingPrecedence()
var err error
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
if err != nil {
return nil, err
@@ -72,14 +61,18 @@ func NewKubernetes() (*Kubernetes, error) {
}
func (k *Kubernetes) WatchKubeConfig(onKubeConfigChange func() error) {
if len(k.kubeConfigFiles) == 0 {
if k.clientCmdConfig == nil {
return
}
kubeConfigFiles := k.clientCmdConfig.ConfigAccess().GetLoadingPrecedence()
if len(kubeConfigFiles) == 0 {
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return
}
for _, file := range k.kubeConfigFiles {
for _, file := range kubeConfigFiles {
_ = watcher.Add(file)
}
go func() {
@@ -131,35 +124,16 @@ func marshal(v any) (string, error) {
return string(ret), nil
}
func resolveConfig() clientcmd.ClientConfig {
pathOptions := clientcmd.NewDefaultPathOptions()
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
}
func resolveClientConfig() (*rest.Config, error) {
inClusterConfig, err := InClusterConfig()
if err == nil && inClusterConfig != nil {
return inClusterConfig, nil
}
cfg, err := resolveConfig().ClientConfig()
if cfg != nil && cfg.UserAgent == "" {
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
return cfg, err
}
func configuredNamespace() string {
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
func (k *Kubernetes) configuredNamespace() string {
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
return ns
}
return ""
}
func namespaceOrDefault(namespace string) string {
func (k *Kubernetes) namespaceOrDefault(namespace string) string {
if namespace == "" {
return configuredNamespace()
return k.configuredNamespace()
}
return namespace
}

View File

@@ -32,11 +32,11 @@ func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string)
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, namespaceOrDefault(namespace), name)
}, k.namespaceOrDefault(namespace), name)
}
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
namespace = namespaceOrDefault(namespace)
namespace = k.namespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
@@ -79,7 +79,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
tailLines := int64(256)
req := k.clientSet.CoreV1().Pods(namespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
req := k.clientSet.CoreV1().Pods(k.namespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
TailLines: &tailLines,
Container: container,
})
@@ -108,7 +108,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
var resources []any
pod := &v1.Pod{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
Spec: v1.PodSpec{Containers: []v1.Container{{
Name: name,
Image: image,
@@ -120,7 +120,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
resources = append(resources, &v1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
Spec: v1.ServiceSpec{
Selector: labels,
Type: v1.ServiceTypeClusterIP,
@@ -135,7 +135,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
"kind": "Route",
"metadata": map[string]interface{}{
"name": name,
"namespace": namespaceOrDefault(namespace),
"namespace": k.namespaceOrDefault(namespace),
"labels": labels,
},
"spec": map[string]interface{}{
@@ -175,7 +175,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = namespaceOrDefault(namespace)
namespace = k.namespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err

View File

@@ -34,7 +34,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = namespaceOrDefault(namespace)
namespace = k.namespaceOrDefault(namespace)
}
rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
@@ -64,7 +64,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = namespaceOrDefault(namespace)
namespace = k.namespaceOrDefault(namespace)
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
@@ -77,7 +77,7 @@ func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersion
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = configuredNamespace()
namespace = k.configuredNamespace()
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
}
@@ -92,7 +92,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
namespace = namespaceOrDefault(namespace)
namespace = k.namespaceOrDefault(namespace)
}
resources[i], rErr = k.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
FieldManager: version.BinaryName,

View File

@@ -105,7 +105,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
c.ctx, c.cancel = context.WithCancel(context.Background())
c.tempDir = t.TempDir()
c.withKubeConfig(nil)
if c.mcpServer, err = NewSever(); err != nil {
if c.mcpServer, err = NewSever(Configuration{}); err != nil {
t.Fatal(err)
return
}

View File

@@ -3,7 +3,6 @@ package mcp
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -16,18 +15,18 @@ func (s *Server) initConfiguration() []server.ServerTool {
"If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. "+
"If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. "+
"(Optional, default true)")),
), configurationView},
), s.configurationView},
}
return tools
}
func configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
minify := true
minified := ctr.Params.Arguments["minified"]
if _, ok := minified.(bool); ok {
minify = minified.(bool)
}
ret, err := kubernetes.ConfigurationView(minify)
ret, err := s.k.ConfigurationView(minify)
if err != nil {
err = fmt.Errorf("failed to get configuration: %v", err)
}

View File

@@ -8,13 +8,19 @@ import (
"slices"
)
type Server struct {
server *server.MCPServer
k *kubernetes.Kubernetes
type Configuration struct {
Kubeconfig string
}
func NewSever() (*Server, error) {
type Server struct {
configuration *Configuration
server *server.MCPServer
k *kubernetes.Kubernetes
}
func NewSever(configuration Configuration) (*Server, error) {
s := &Server{
configuration: &configuration,
server: server.NewMCPServer(
version.BinaryName,
version.Version,
@@ -32,7 +38,7 @@ func NewSever() (*Server, error) {
}
func (s *Server) reloadKubernetesClient() error {
k, err := kubernetes.NewKubernetes()
k, err := kubernetes.NewKubernetes(s.configuration.Kubeconfig)
if err != nil {
return err
}

View File

@@ -13,7 +13,7 @@ import (
)
func TestWatchKubeConfig(t *testing.T) {
if runtime.GOOS != "linux" {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("Skipping test on non-linux platforms")
}
testCase(t, func(c *mcpContext) {