Repository: containers/kubernetes-mcp-server

Issue:
Configuration file
## Description

As a user/mcp admin, I want to be able to start the server using a configuration file, so that I can easily share configurations and simplify the command entry-point.

Configuration command flags should remain available to be able to override some of the configurations provided in the file for specific command invocations (one of).

Initially discussed providing the configuration as a toml file (similar to what other Kubernetes ecosystem tools accept).

- Config file based on TOML format
- Config file can be provided to the MCP server through the `--config` command flag

Discussion:

Solution patch:
diff --git a/go.mod b/go.mod
index 539795f..ae54c1b 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,7 @@ require (
 	k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
 	sigs.k8s.io/controller-runtime v0.21.0
 	sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
-	sigs.k8s.io/yaml v1.4.0
+	sigs.k8s.io/yaml v1.5.0
 )
 
 require (
@@ -105,6 +105,8 @@ require (
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	github.com/xlab/treeprint v1.2.0 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	go.yaml.in/yaml/v2 v2.4.2 // indirect
+	go.yaml.in/yaml/v3 v3.0.3 // indirect
 	golang.org/x/crypto v0.39.0 // indirect
 	golang.org/x/net v0.41.0 // indirect
 	golang.org/x/oauth2 v0.28.0 // indirect
diff --git a/go.sum b/go.sum
index f35f9b4..14f8f17 100644
--- a/go.sum
+++ b/go.sum
@@ -354,6 +354,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
+go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -471,5 +475,6 @@ sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
 sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
 sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
 sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
-sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
 sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
+sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=



Test patch:
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index a36fc83..bf862ef 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -3,42 +3,76 @@ package config
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 )
 
-func TestReadConfig(t *testing.T) {
-	tempDir := t.TempDir()
+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)
+		}
+	})
+}
 
-	t.Run("ValidConfigFileWithDeniedResources", func(t *testing.T) {
-		validConfigContent := `
+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"
-`
-		validConfigPath := filepath.Join(tempDir, "valid_denied_config.toml")
-		err := os.WriteFile(validConfigPath, []byte(validConfigContent), 0644)
-		if err != nil {
-			t.Fatalf("Failed to write valid config file: %v", err)
+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)
+		}
+	})
+}
 
-		config, err := ReadConfig(validConfigPath)
+func TestReadConfigValid(t *testing.T) {
+	validConfigPath := writeConfig(t, `
+[[denied_resources]]
+group = "apps"
+version = "v1"
+kind = "Deployment"
+
+[[denied_resources]]
+group = "rbac.authorization.k8s.io"
+version = "v1"
+`)
+
+	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" {
@@ -46,3 +80,14 @@ version = "v1"
 		}
 	})
 }
+
+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
+}
diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index edda918..85b1861 100644
--- a/pkg/kubernetes-mcp-server/cmd/root_test.go
+++ b/pkg/kubernetes-mcp-server/cmd/root_test.go
@@ -66,6 +66,19 @@ func TestConfig(t *testing.T) {
 			t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
 		}
 	})
+	t.Run("invalid path throws error", func(t *testing.T) {
+		ioStreams, _ := testStream()
+		rootCmd := NewMCPServer(ioStreams)
+		rootCmd.SetArgs([]string{"--version", "--log-level=1", "--config", "invalid-path-to-config.toml"})
+		err := rootCmd.Execute()
+		if err == nil {
+			t.Fatal("Expected error for invalid config path, got nil")
+		}
+		expected := "open invalid-path-to-config.toml: "
+		if !strings.HasPrefix(err.Error(), expected) {
+			t.Fatalf("Expected error to be %s, got %s", expected, err.Error())
+		}
+	})
 }
 
 func TestProfile(t *testing.T) {