Skip to content

Commit fa5bb81

Browse files
committed
feat(kubernetes): added --kubeconfig flag option
1 parent 5d3c7f3 commit fa5bb81

File tree

11 files changed

+241
-78
lines changed

11 files changed

+241
-78
lines changed

README.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,11 @@ npx kubernetes-mcp-server@latest --help
130130

131131
### Configuration Options
132132

133-
| Option | Description |
134-
|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
135-
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
136-
| `--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). |
133+
| Option | Description |
134+
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
135+
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
136+
| `--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). |
137+
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
137138

138139
## 🛠️ Tools <a id="tools"></a>
139140

pkg/kubernetes-mcp-server/cmd/root.go

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

pkg/kubernetes/configuration.go

+53-7
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,75 @@
11
package kubernetes
22

33
import (
4+
"k8s.io/client-go/rest"
5+
"k8s.io/client-go/tools/clientcmd"
46
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
57
"k8s.io/client-go/tools/clientcmd/api/latest"
68
)
79

8-
func ConfigurationView(minify bool) (string, error) {
10+
// InClusterConfig is a variable that holds the function to get the in-cluster config
11+
// Exposed for testing
12+
var InClusterConfig = func() (*rest.Config, error) {
13+
// TODO use kubernetes.default.svc instead of resolved server
14+
// Currently running into: `http: server gave HTTP response to HTTPS client`
15+
inClusterConfig, err := rest.InClusterConfig()
16+
if inClusterConfig != nil {
17+
inClusterConfig.Host = "https://kubernetes.default.svc"
18+
}
19+
return inClusterConfig, err
20+
}
21+
22+
// resolveKubernetesConfigurations resolves the required kubernetes configurations and sets them in the Kubernetes struct
23+
func resolveKubernetesConfigurations(kubernetes *Kubernetes) error {
24+
// Always set clientCmdConfig
25+
pathOptions := clientcmd.NewDefaultPathOptions()
26+
if kubernetes.Kubeconfig != "" {
27+
pathOptions.LoadingRules.ExplicitPath = kubernetes.Kubeconfig
28+
}
29+
kubernetes.clientCmdConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
30+
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
31+
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
32+
var err error
33+
if kubernetes.IsInCluster() {
34+
kubernetes.cfg, err = InClusterConfig()
35+
if err == nil && kubernetes.cfg != nil {
36+
return nil
37+
}
38+
}
39+
// Out of cluster
40+
kubernetes.cfg, err = kubernetes.clientCmdConfig.ClientConfig()
41+
if kubernetes.cfg != nil && kubernetes.cfg.UserAgent == "" {
42+
kubernetes.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
43+
}
44+
return err
45+
}
46+
47+
func (k *Kubernetes) IsInCluster() bool {
48+
if k.Kubeconfig != "" {
49+
return false
50+
}
51+
cfg, err := InClusterConfig()
52+
return err == nil && cfg != nil
53+
}
54+
55+
func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
956
var cfg clientcmdapi.Config
1057
var err error
11-
inClusterConfig, err := InClusterConfig()
12-
if err == nil && inClusterConfig != nil {
58+
if k.IsInCluster() {
1359
cfg = *clientcmdapi.NewConfig()
1460
cfg.Clusters["cluster"] = &clientcmdapi.Cluster{
15-
Server: inClusterConfig.Host,
16-
InsecureSkipTLSVerify: inClusterConfig.Insecure,
61+
Server: k.cfg.Host,
62+
InsecureSkipTLSVerify: k.cfg.Insecure,
1763
}
1864
cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{
19-
Token: inClusterConfig.BearerToken,
65+
Token: k.cfg.BearerToken,
2066
}
2167
cfg.Contexts["context"] = &clientcmdapi.Context{
2268
Cluster: "cluster",
2369
AuthInfo: "user",
2470
}
2571
cfg.CurrentContext = "context"
26-
} else if cfg, err = resolveConfig().RawConfig(); err != nil {
72+
} else if cfg, err = k.clientCmdConfig.RawConfig(); err != nil {
2773
return "", err
2874
}
2975
if minify {

pkg/kubernetes/configuration_test.go

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package kubernetes
2+
3+
import (
4+
"errors"
5+
"k8s.io/client-go/rest"
6+
"os"
7+
"path"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestKubernetes_IsInCluster(t *testing.T) {
13+
t.Run("with explicit kubeconfig", func(t *testing.T) {
14+
k := Kubernetes{
15+
Kubeconfig: "kubeconfig",
16+
}
17+
if k.IsInCluster() {
18+
t.Errorf("expected not in cluster, got in cluster")
19+
}
20+
})
21+
t.Run("with empty kubeconfig and in cluster", func(t *testing.T) {
22+
originalFunction := InClusterConfig
23+
InClusterConfig = func() (*rest.Config, error) {
24+
return &rest.Config{}, nil
25+
}
26+
defer func() {
27+
InClusterConfig = originalFunction
28+
}()
29+
k := Kubernetes{
30+
Kubeconfig: "",
31+
}
32+
if !k.IsInCluster() {
33+
t.Errorf("expected in cluster, got not in cluster")
34+
}
35+
})
36+
t.Run("with empty kubeconfig and not in cluster (empty)", func(t *testing.T) {
37+
originalFunction := InClusterConfig
38+
InClusterConfig = func() (*rest.Config, error) {
39+
return nil, nil
40+
}
41+
defer func() {
42+
InClusterConfig = originalFunction
43+
}()
44+
k := Kubernetes{
45+
Kubeconfig: "",
46+
}
47+
if k.IsInCluster() {
48+
t.Errorf("expected not in cluster, got in cluster")
49+
}
50+
})
51+
t.Run("with empty kubeconfig and not in cluster (error)", func(t *testing.T) {
52+
originalFunction := InClusterConfig
53+
InClusterConfig = func() (*rest.Config, error) {
54+
return nil, errors.New("error")
55+
}
56+
defer func() {
57+
InClusterConfig = originalFunction
58+
}()
59+
k := Kubernetes{
60+
Kubeconfig: "",
61+
}
62+
if k.IsInCluster() {
63+
t.Errorf("expected not in cluster, got in cluster")
64+
}
65+
})
66+
}
67+
68+
func TestKubernetes_ResolveKubernetesConfigurations_Explicit(t *testing.T) {
69+
t.Run("with missing file", func(t *testing.T) {
70+
tempDir := t.TempDir()
71+
k := Kubernetes{Kubeconfig: path.Join(tempDir, "config")}
72+
err := resolveKubernetesConfigurations(&k)
73+
if err == nil {
74+
t.Errorf("expected error, got nil")
75+
}
76+
if !errors.Is(err, os.ErrNotExist) {
77+
t.Errorf("expected file not found error, got %v", err)
78+
}
79+
if !strings.HasSuffix(err.Error(), ": no such file or directory") {
80+
t.Errorf("expected file not found error, got %v", err)
81+
}
82+
})
83+
t.Run("with empty file", func(t *testing.T) {
84+
tempDir := t.TempDir()
85+
kubeconfigPath := path.Join(tempDir, "config")
86+
if err := os.WriteFile(kubeconfigPath, []byte(""), 0644); err != nil {
87+
t.Fatalf("failed to create kubeconfig file: %v", err)
88+
}
89+
k := Kubernetes{Kubeconfig: kubeconfigPath}
90+
err := resolveKubernetesConfigurations(&k)
91+
if err == nil {
92+
t.Errorf("expected error, got nil")
93+
}
94+
if !strings.Contains(err.Error(), "no configuration has been provided") {
95+
t.Errorf("expected no kubeconfig error, got %v", err)
96+
}
97+
})
98+
t.Run("with valid file", func(t *testing.T) {
99+
tempDir := t.TempDir()
100+
kubeconfigPath := path.Join(tempDir, "config")
101+
kubeconfigContent := `
102+
apiVersion: v1
103+
kind: Config
104+
clusters:
105+
- cluster:
106+
server: https://example.com
107+
name: example-cluster
108+
contexts:
109+
- context:
110+
cluster: example-cluster
111+
user: example-user
112+
name: example-context
113+
current-context: example-context
114+
users:
115+
- name: example-user
116+
user:
117+
token: example-token
118+
`
119+
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644); err != nil {
120+
t.Fatalf("failed to create kubeconfig file: %v", err)
121+
}
122+
k := Kubernetes{Kubeconfig: kubeconfigPath}
123+
err := resolveKubernetesConfigurations(&k)
124+
if err != nil {
125+
t.Fatalf("expected no error, got %v", err)
126+
}
127+
if k.cfg == nil {
128+
t.Errorf("expected non-nil config, got nil")
129+
}
130+
if k.cfg.Host != "https://example.com" {
131+
t.Errorf("expected host https://example.com, got %s", k.cfg.Host)
132+
}
133+
})
134+
}

pkg/kubernetes/kubernetes.go

+19-45
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,16 @@ import (
1212
"k8s.io/client-go/rest"
1313
"k8s.io/client-go/restmapper"
1414
"k8s.io/client-go/tools/clientcmd"
15-
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
1615
"sigs.k8s.io/yaml"
1716
)
1817

19-
// InClusterConfig is a variable that holds the function to get the in-cluster config
20-
// Exposed for testing
21-
var InClusterConfig = func() (*rest.Config, error) {
22-
// TODO use kubernetes.default.svc instead of resolved server
23-
// Currently running into: `http: server gave HTTP response to HTTPS client`
24-
inClusterConfig, err := rest.InClusterConfig()
25-
if inClusterConfig != nil {
26-
inClusterConfig.Host = "https://kubernetes.default.svc"
27-
}
28-
return inClusterConfig, err
29-
}
30-
3118
type CloseWatchKubeConfig func() error
3219

3320
type Kubernetes struct {
21+
// Kubeconfig path override
22+
Kubeconfig string
3423
cfg *rest.Config
35-
kubeConfigFiles []string
24+
clientCmdConfig clientcmd.ClientConfig
3625
CloseWatchKubeConfig CloseWatchKubeConfig
3726
scheme *runtime.Scheme
3827
parameterCodec runtime.ParameterCodec
@@ -42,14 +31,14 @@ type Kubernetes struct {
4231
dynamicClient *dynamic.DynamicClient
4332
}
4433

45-
func NewKubernetes() (*Kubernetes, error) {
46-
k8s := &Kubernetes{}
47-
var err error
48-
k8s.cfg, err = resolveClientConfig()
49-
if err != nil {
34+
func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
35+
k8s := &Kubernetes{
36+
Kubeconfig: kubeconfig,
37+
}
38+
if err := resolveKubernetesConfigurations(k8s); err != nil {
5039
return nil, err
5140
}
52-
k8s.kubeConfigFiles = resolveConfig().ConfigAccess().GetLoadingPrecedence()
41+
var err error
5342
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
5443
if err != nil {
5544
return nil, err
@@ -72,14 +61,18 @@ func NewKubernetes() (*Kubernetes, error) {
7261
}
7362

7463
func (k *Kubernetes) WatchKubeConfig(onKubeConfigChange func() error) {
75-
if len(k.kubeConfigFiles) == 0 {
64+
if k.clientCmdConfig == nil {
65+
return
66+
}
67+
kubeConfigFiles := k.clientCmdConfig.ConfigAccess().GetLoadingPrecedence()
68+
if len(kubeConfigFiles) == 0 {
7669
return
7770
}
7871
watcher, err := fsnotify.NewWatcher()
7972
if err != nil {
8073
return
8174
}
82-
for _, file := range k.kubeConfigFiles {
75+
for _, file := range kubeConfigFiles {
8376
_ = watcher.Add(file)
8477
}
8578
go func() {
@@ -131,35 +124,16 @@ func marshal(v any) (string, error) {
131124
return string(ret), nil
132125
}
133126

134-
func resolveConfig() clientcmd.ClientConfig {
135-
pathOptions := clientcmd.NewDefaultPathOptions()
136-
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
137-
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
138-
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
139-
}
140-
141-
func resolveClientConfig() (*rest.Config, error) {
142-
inClusterConfig, err := InClusterConfig()
143-
if err == nil && inClusterConfig != nil {
144-
return inClusterConfig, nil
145-
}
146-
cfg, err := resolveConfig().ClientConfig()
147-
if cfg != nil && cfg.UserAgent == "" {
148-
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
149-
}
150-
return cfg, err
151-
}
152-
153-
func configuredNamespace() string {
154-
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
127+
func (k *Kubernetes) configuredNamespace() string {
128+
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
155129
return ns
156130
}
157131
return ""
158132
}
159133

160-
func namespaceOrDefault(namespace string) string {
134+
func (k *Kubernetes) namespaceOrDefault(namespace string) string {
161135
if namespace == "" {
162-
return configuredNamespace()
136+
return k.configuredNamespace()
163137
}
164138
return namespace
165139
}

0 commit comments

Comments
 (0)