diff --git a/README.md b/README.md
index c6030bdd..01b42947 100644
--- a/README.md
+++ b/README.md
@@ -189,6 +189,8 @@ uvx kubernetes-mcp-server@latest --help
|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--port` | Starts the MCP server in Streamable HTTP mode (path /mcp) and Server-Sent Event (SSE) (path /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). |
+| `--config` | (Optional) Path to the main TOML configuration file. See [Drop-in Configuration](#drop-in-configuration) section below for details. |
+| `--config-dir` | (Optional) Path to drop-in configuration directory. Files are loaded in lexical (alphabetical) order. See [Drop-in Configuration](#drop-in-configuration) section below for details. |
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
| `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. |
@@ -196,6 +198,94 @@ uvx kubernetes-mcp-server@latest --help
| `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
| `--disable-multi-cluster` | If set, the MCP server will disable multi-cluster support and will only use the current context from the kubeconfig file. This is useful if you want to restrict the MCP server to a single cluster. |
+### Drop-in Configuration
+
+The Kubernetes MCP server supports flexible configuration through both a main config file and drop-in files. **Both are optional** - you can use either, both, or neither (server will use built-in defaults).
+
+#### Configuration Loading Order
+
+Configuration values are loaded and merged in the following order (later sources override earlier ones):
+
+1. **Internal Defaults** - Always loaded (hardcoded default values)
+2. **Main Configuration File** - Optional, loaded via `--config` flag
+3. **Drop-in Files** - Optional, loaded from `--config-dir` in **lexical (alphabetical) order**
+
+#### How Drop-in Files Work
+
+- **File Naming**: Use numeric prefixes to control loading order (e.g., `00-base.toml`, `10-cluster.toml`, `99-override.toml`)
+- **File Extension**: Only `.toml` files are processed; dotfiles (starting with `.`) are ignored
+- **Partial Configuration**: Drop-in files can contain only a subset of configuration options
+- **Merge Behavior**: Values present in a drop-in file override previous values; missing values are preserved
+
+#### Dynamic Configuration Reload
+
+To reload configuration after modifying config files, send a `SIGHUP` signal to the running server process.
+
+**Prerequisite**: SIGHUP reload requires the server to be started with either the `--config` flag or `--config-dir` flag (or both). If neither is specified, SIGHUP signals will be ignored.
+
+**How to reload:**
+
+```shell
+# Find the process ID
+ps aux | grep kubernetes-mcp-server
+
+# Send SIGHUP to reload configuration
+kill -HUP
+
+# Or use pkill
+pkill -HUP kubernetes-mcp-server
+```
+
+The server will:
+- Reload the main config file and all drop-in files
+- Update configuration values (log level, output format, etc.)
+- Rebuild the toolset registry with new tool configurations
+- Log the reload status
+
+**Note**: Changing `kubeconfig` or cluster-related settings requires a server restart. Only tool configurations, log levels, and output formats can be reloaded dynamically.
+
+**Note**: SIGHUP reload is not available on Windows. On Windows, restart the server to reload configuration.
+
+#### Example: Using Both Config Methods
+
+**Command:**
+```shell
+kubernetes-mcp-server --config /etc/kubernetes-mcp-server/config.toml \
+ --config-dir /etc/kubernetes-mcp-server/config.d/
+```
+
+**Directory structure:**
+```
+/etc/kubernetes-mcp-server/
+├── config.toml # Main configuration
+└── config.d/
+ ├── 00-base.toml # Base overrides
+ ├── 10-toolsets.toml # Toolset-specific config
+ └── 99-local.toml # Local overrides
+```
+
+**Example drop-in file** (`10-toolsets.toml`):
+```toml
+# Override only the toolsets - all other config preserved
+toolsets = ["core", "config", "helm", "logs"]
+```
+
+**Example drop-in file** (`99-local.toml`):
+```toml
+# Local development overrides
+log_level = 9
+read_only = true
+```
+
+**To apply changes:**
+```shell
+# Edit config files
+vim /etc/kubernetes-mcp-server/config.d/99-local.toml
+
+# Reload without restarting
+pkill -HUP kubernetes-mcp-server
+```
+
## 🛠️ Tools and Functionalities
The Kubernetes MCP server supports enabling or disabling specific groups of tools and functionalities (tools, resources, prompts, and so on) via the `--toolsets` command-line flag or `toolsets` configuration option.
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 85bf74d0..f435a8a1 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -6,8 +6,11 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
+ "strings"
"github.com/BurntSushi/toml"
+ "k8s.io/klog/v2"
)
const (
@@ -96,26 +99,152 @@ func WithDirPath(path string) ReadConfigOpt {
}
}
-// Read reads the toml file and returns the StaticConfig, with any opts applied.
-func Read(configPath string, opts ...ReadConfigOpt) (*StaticConfig, error) {
- configData, err := os.ReadFile(configPath)
+// Read reads the toml file, applies drop-in configs from configDir (if provided),
+// and returns the StaticConfig with any opts applied.
+// Loading order: defaults → main config file → drop-in files (lexically sorted)
+func Read(configPath string, configDir string, opts ...ReadConfigOpt) (*StaticConfig, error) {
+ // Start with defaults
+ cfg := Default()
+
+ // Get the absolute dir path for the main config file
+ var dirPath string
+ if configPath != "" {
+ // get and save the absolute dir path to the config file, so that other config parsers can use it
+ absPath, err := filepath.Abs(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
+ }
+ dirPath = filepath.Dir(absPath)
+
+ // Load main config file
+ klog.V(2).Infof("Loading main config from: %s", configPath)
+ if err := mergeConfigFile(cfg, configPath, append(opts, WithDirPath(dirPath))...); err != nil {
+ return nil, fmt.Errorf("failed to load main config file %s: %w", configPath, err)
+ }
+ }
+
+ // Load drop-in config files if directory is specified
+ if configDir != "" {
+ if err := loadDropInConfigs(cfg, configDir, append(opts, WithDirPath(dirPath))...); err != nil {
+ return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", configDir, err)
+ }
+ }
+
+ return cfg, nil
+}
+
+// mergeConfigFile reads a config file and merges its values into the target config.
+// Values present in the file will overwrite existing values in cfg.
+// Values not present in the file will remain unchanged in cfg.
+func mergeConfigFile(cfg *StaticConfig, filePath string, opts ...ReadConfigOpt) error {
+ configData, err := os.ReadFile(filePath)
if err != nil {
- return nil, err
+ return err
}
- // get and save the absolute dir path to the config file, so that other config parsers can use it
- absPath, err := filepath.Abs(configPath)
+ md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(cfg)
if err != nil {
- return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
+ return fmt.Errorf("failed to decode TOML: %w", err)
}
- dirPath := filepath.Dir(absPath)
- cfg, err := ReadToml(configData, append(opts, WithDirPath(dirPath))...)
+ for _, opt := range opts {
+ opt(cfg)
+ }
+
+ ctx := withConfigDirPath(context.Background(), cfg.configDirPath)
+
+ cfg.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, cfg.ClusterProviderConfigs)
if err != nil {
- return nil, err
+ return err
}
- return cfg, nil
+ cfg.parsedToolsetConfigs, err = toolsetConfigRegistry.parse(ctx, md, cfg.ToolsetConfigs)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// loadDropInConfigs loads and merges config files from a drop-in directory.
+// Files are processed in lexical (alphabetical) order.
+// Only files with .toml extension are processed; dotfiles are ignored.
+func loadDropInConfigs(cfg *StaticConfig, dropInDir string, opts ...ReadConfigOpt) error {
+ // Check if directory exists
+ info, err := os.Stat(dropInDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInDir)
+ return nil
+ }
+ return fmt.Errorf("failed to stat drop-in directory: %w", err)
+ }
+
+ if !info.IsDir() {
+ return fmt.Errorf("drop-in config path is not a directory: %s", dropInDir)
+ }
+
+ // Get all .toml files in the directory
+ files, err := getSortedConfigFiles(dropInDir)
+ if err != nil {
+ return err
+ }
+
+ if len(files) == 0 {
+ klog.V(2).Infof("No drop-in config files found in: %s", dropInDir)
+ return nil
+ }
+
+ klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(files), dropInDir)
+
+ // Merge each file in order
+ for _, file := range files {
+ klog.V(3).Infof(" - Merging drop-in config: %s", filepath.Base(file))
+ if err := mergeConfigFile(cfg, file, opts...); err != nil {
+ return fmt.Errorf("failed to merge drop-in config %s: %w", file, err)
+ }
+ }
+
+ return nil
+}
+
+// getSortedConfigFiles returns a sorted list of .toml files in the specified directory.
+// Dotfiles (starting with '.') and non-.toml files are ignored.
+// Files are sorted lexically (alphabetically) by filename.
+func getSortedConfigFiles(dir string) ([]string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read directory: %w", err)
+ }
+
+ var files []string
+ for _, entry := range entries {
+ // Skip directories
+ if entry.IsDir() {
+ continue
+ }
+
+ name := entry.Name()
+
+ // Skip dotfiles
+ if strings.HasPrefix(name, ".") {
+ klog.V(4).Infof("Skipping dotfile: %s", name)
+ continue
+ }
+
+ // Only process .toml files
+ if !strings.HasSuffix(name, ".toml") {
+ klog.V(4).Infof("Skipping non-.toml file: %s", name)
+ continue
+ }
+
+ files = append(files, filepath.Join(dir, name))
+ }
+
+ // Sort lexically
+ sort.Strings(files)
+
+ return files, nil
}
// ReadToml reads the toml data and returns the StaticConfig, with any opts applied
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index b3fb2b4f..33e38df0 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -31,7 +31,7 @@ type ConfigSuite struct {
}
func (s *ConfigSuite) TestReadConfigMissingFile() {
- config, err := Read("non-existent-config.toml")
+ config, err := Read("non-existent-config.toml", "")
s.Run("returns error for missing file", func() {
s.Require().NotNil(err, "Expected error for missing file, got nil")
s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
@@ -53,13 +53,13 @@ func (s *ConfigSuite) TestReadConfigInvalid() {
kind = "Role
`)
- config, err := Read(invalidConfigPath)
+ config, err := Read(invalidConfigPath, "")
s.Run("returns error for invalid file", func() {
s.Require().NotNil(err, "Expected error for invalid file, got nil")
})
s.Run("error message contains toml error with line number", func() {
expectedError := "toml: line 9"
- s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
+ s.Truef(strings.Contains(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
})
s.Run("returns nil config for invalid file", func() {
s.Nil(config, "Expected nil config for missing file")
@@ -88,7 +88,7 @@ func (s *ConfigSuite) TestReadConfigValid() {
`)
- config, err := Read(validConfigPath)
+ config, err := Read(validConfigPath, "")
s.Require().NotNil(config)
s.Run("reads and unmarshalls file", func() {
s.Nil(err, "Expected nil error for valid file")
@@ -154,7 +154,7 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
port = "1337"
`)
- config, err := Read(validConfigPath)
+ config, err := Read(validConfigPath, "")
s.Require().NotNil(config)
s.Run("reads and unmarshalls file", func() {
s.Nil(err, "Expected nil error for valid file")
@@ -177,46 +177,209 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
})
}
-func (s *ConfigSuite) TestMergeConfig() {
- base := StaticConfig{
- ListOutput: "table",
- Toolsets: []string{"core", "config", "helm"},
- Port: "8080",
+func (s *ConfigSuite) TestGetSortedConfigFiles() {
+ tempDir := s.T().TempDir()
+
+ // Create test files
+ files := []string{
+ "10-first.toml",
+ "20-second.toml",
+ "05-before.toml",
+ "99-last.toml",
+ ".hidden.toml", // should be ignored
+ "readme.txt", // should be ignored
+ "invalid", // should be ignored
}
- s.Run("merges override values on top of base", func() {
- override := StaticConfig{
- ListOutput: "json",
- Port: "9090",
+
+ for _, file := range files {
+ path := filepath.Join(tempDir, file)
+ err := os.WriteFile(path, []byte(""), 0644)
+ s.Require().NoError(err)
+ }
+
+ // Create a subdirectory (should be ignored)
+ subDir := filepath.Join(tempDir, "subdir")
+ err := os.Mkdir(subDir, 0755)
+ s.Require().NoError(err)
+
+ sorted, err := getSortedConfigFiles(tempDir)
+ s.Require().NoError(err)
+
+ s.Run("returns only .toml files", func() {
+ s.Len(sorted, 4, "Expected 4 .toml files")
+ })
+
+ s.Run("sorted in lexical order", func() {
+ expected := []string{
+ filepath.Join(tempDir, "05-before.toml"),
+ filepath.Join(tempDir, "10-first.toml"),
+ filepath.Join(tempDir, "20-second.toml"),
+ filepath.Join(tempDir, "99-last.toml"),
}
+ s.Equal(expected, sorted)
+ })
+
+ s.Run("excludes dotfiles", func() {
+ for _, file := range sorted {
+ s.NotContains(file, ".hidden")
+ }
+ })
+
+ s.Run("excludes non-.toml files", func() {
+ for _, file := range sorted {
+ s.Contains(file, ".toml")
+ }
+ })
+}
- result := mergeConfig(base, override)
+func (s *ConfigSuite) TestDropInConfigPrecedence() {
+ tempDir := s.T().TempDir()
+
+ // Main config file
+ mainConfigPath := s.writeConfig(`
+ log_level = 1
+ port = "8080"
+ list_output = "table"
+ toolsets = ["core", "config"]
+ `)
+
+ // Create drop-in directory
+ dropInDir := filepath.Join(tempDir, "config.d")
+ err := os.Mkdir(dropInDir, 0755)
+ s.Require().NoError(err)
- s.Equal("json", result.ListOutput, "ListOutput should be overridden")
- s.Equal("9090", result.Port, "Port should be overridden")
+ // First drop-in file
+ dropIn1 := filepath.Join(dropInDir, "10-override.toml")
+ err = os.WriteFile(dropIn1, []byte(`
+ log_level = 5
+ port = "9090"
+ `), 0644)
+ s.Require().NoError(err)
+
+ // Second drop-in file (should override first)
+ dropIn2 := filepath.Join(dropInDir, "20-final.toml")
+ err = os.WriteFile(dropIn2, []byte(`
+ port = "7777"
+ list_output = "yaml"
+ `), 0644)
+ s.Require().NoError(err)
+
+ config, err := Read(mainConfigPath, dropInDir)
+ s.Require().NoError(err)
+ s.Require().NotNil(config)
+
+ s.Run("drop-in overrides main config", func() {
+ s.Equal(5, config.LogLevel, "log_level from 10-override.toml should override main")
})
- s.Run("preserves base values when override is empty", func() {
- override := StaticConfig{}
+ s.Run("later drop-in overrides earlier drop-in", func() {
+ s.Equal("7777", config.Port, "port from 20-final.toml should override 10-override.toml")
+ })
- result := mergeConfig(base, override)
+ s.Run("preserves values not in drop-in files", func() {
+ s.Equal([]string{"core", "config"}, config.Toolsets, "toolsets from main config should be preserved")
+ })
- s.Equal("table", result.ListOutput, "ListOutput should be preserved from base")
- s.Equal([]string{"core", "config", "helm"}, result.Toolsets, "Toolsets should be preserved from base")
- s.Equal("8080", result.Port, "Port should be preserved from base")
+ s.Run("applies all drop-in changes", func() {
+ s.Equal("yaml", config.ListOutput, "list_output from 20-final.toml should be applied")
})
+}
- s.Run("handles partial overrides", func() {
- override := StaticConfig{
- Toolsets: []string{"custom"},
- ReadOnly: true,
- }
+func (s *ConfigSuite) TestDropInConfigMissingDirectory() {
+ mainConfigPath := s.writeConfig(`
+ log_level = 3
+ port = "8080"
+ `)
+
+ config, err := Read(mainConfigPath, "/non/existent/directory")
+ s.Require().NoError(err, "Should not error for missing drop-in directory")
+ s.Require().NotNil(config)
- result := mergeConfig(base, override)
+ s.Run("loads main config successfully", func() {
+ s.Equal(3, config.LogLevel)
+ s.Equal("8080", config.Port)
+ })
+}
+
+func (s *ConfigSuite) TestDropInConfigEmptyDirectory() {
+ mainConfigPath := s.writeConfig(`
+ log_level = 2
+ `)
+
+ dropInDir := s.T().TempDir()
+
+ config, err := Read(mainConfigPath, dropInDir)
+ s.Require().NoError(err)
+ s.Require().NotNil(config)
+
+ s.Run("loads main config successfully", func() {
+ s.Equal(2, config.LogLevel)
+ })
+}
+
+func (s *ConfigSuite) TestDropInConfigPartialOverride() {
+ tempDir := s.T().TempDir()
+
+ mainConfigPath := s.writeConfig(`
+ log_level = 1
+ port = "8080"
+ list_output = "table"
+ read_only = false
+ toolsets = ["core", "config", "helm"]
+ `)
+
+ dropInDir := filepath.Join(tempDir, "config.d")
+ err := os.Mkdir(dropInDir, 0755)
+ s.Require().NoError(err)
+
+ // Drop-in file with partial config
+ dropIn := filepath.Join(dropInDir, "10-partial.toml")
+ err = os.WriteFile(dropIn, []byte(`
+ read_only = true
+ `), 0644)
+ s.Require().NoError(err)
+
+ config, err := Read(mainConfigPath, dropInDir)
+ s.Require().NoError(err)
+ s.Require().NotNil(config)
+
+ s.Run("overrides specified field", func() {
+ s.True(config.ReadOnly, "read_only should be overridden to true")
+ })
+
+ s.Run("preserves all other fields", func() {
+ s.Equal(1, config.LogLevel)
+ s.Equal("8080", config.Port)
+ s.Equal("table", config.ListOutput)
+ s.Equal([]string{"core", "config", "helm"}, config.Toolsets)
+ })
+}
+
+func (s *ConfigSuite) TestDropInConfigWithArrays() {
+ tempDir := s.T().TempDir()
+
+ mainConfigPath := s.writeConfig(`
+ toolsets = ["core", "config"]
+ enabled_tools = ["tool1", "tool2"]
+ `)
+
+ dropInDir := filepath.Join(tempDir, "config.d")
+ err := os.Mkdir(dropInDir, 0755)
+ s.Require().NoError(err)
+
+ dropIn := filepath.Join(dropInDir, "10-arrays.toml")
+ err = os.WriteFile(dropIn, []byte(`
+ toolsets = ["helm", "logs"]
+ `), 0644)
+ s.Require().NoError(err)
+
+ config, err := Read(mainConfigPath, dropInDir)
+ s.Require().NoError(err)
+ s.Require().NotNil(config)
- s.Equal("table", result.ListOutput, "ListOutput should be preserved from base")
- s.Equal([]string{"custom"}, result.Toolsets, "Toolsets should be overridden")
- s.Equal("8080", result.Port, "Port should be preserved from base since override doesn't specify it")
- s.True(result.ReadOnly, "ReadOnly should be overridden to true")
+ s.Run("replaces arrays completely", func() {
+ s.Equal([]string{"helm", "logs"}, config.Toolsets, "toolsets should be completely replaced")
+ s.Equal([]string{"tool1", "tool2"}, config.EnabledTools, "enabled_tools should be preserved")
})
}
diff --git a/pkg/config/provider_config_test.go b/pkg/config/provider_config_test.go
index 2afbd2d7..848041c2 100644
--- a/pkg/config/provider_config_test.go
+++ b/pkg/config/provider_config_test.go
@@ -66,7 +66,7 @@ func (s *ProviderConfigSuite) TestReadConfigValid() {
int_prop = 42
`)
- config, err := Read(validConfigPath)
+ config, err := Read(validConfigPath, "")
s.Run("returns no error for valid file with registered provider config", func() {
s.Require().NoError(err, "Expected no error for valid file, got %v", err)
})
@@ -95,7 +95,7 @@ func (s *ProviderConfigSuite) TestReadConfigInvalidProviderConfig() {
int_prop = 42
`)
- config, err := Read(invalidConfigPath)
+ config, err := Read(invalidConfigPath, "")
s.Run("returns error for invalid provider config", func() {
s.Require().NotNil(err, "Expected error for invalid provider config, got nil")
s.ErrorContains(err, "validation error forced by test", "Expected validation error from provider config")
@@ -114,7 +114,7 @@ func (s *ProviderConfigSuite) TestReadConfigUnregisteredProviderConfig() {
int_prop = 42
`)
- config, err := Read(invalidConfigPath)
+ config, err := Read(invalidConfigPath, "")
s.Run("returns no error for unregistered provider config", func() {
s.Require().NoError(err, "Expected no error for unregistered provider config, got %v", err)
})
@@ -139,7 +139,7 @@ func (s *ProviderConfigSuite) TestReadConfigParserError() {
int_prop = 42
`)
- config, err := Read(invalidConfigPath)
+ config, err := Read(invalidConfigPath, "")
s.Run("returns error for provider config parser error", func() {
s.Require().NotNil(err, "Expected error for provider config parser error, got nil")
s.ErrorContains(err, "parser error forced by test", "Expected parser error from provider config")
@@ -170,7 +170,7 @@ func (s *ProviderConfigSuite) TestConfigDirPathInContext() {
absConfigPath, err := filepath.Abs(configPath)
s.Require().NoError(err, "test error: getting the absConfigPath should not fail")
- _, err = Read(configPath)
+ _, err = Read(configPath, "")
s.Run("provides config directory path in context to parser", func() {
s.Require().NoError(err, "Expected no error reading config")
s.NotEmpty(capturedDirPath, "Expected non-empty directory path in context")
diff --git a/pkg/config/toolset_config_test.go b/pkg/config/toolset_config_test.go
index 86f79c66..5b0c236c 100644
--- a/pkg/config/toolset_config_test.go
+++ b/pkg/config/toolset_config_test.go
@@ -64,7 +64,7 @@ func (s *ToolsetConfigSuite) TestReadConfigValid() {
timeout = 30
`)
- config, err := Read(validConfigPath)
+ config, err := Read(validConfigPath, "")
s.Run("returns no error for valid file with registered toolset config", func() {
s.Require().NoError(err, "Expected no error for valid file, got %v", err)
})
@@ -92,7 +92,7 @@ func (s *ToolsetConfigSuite) TestReadConfigInvalidToolsetConfig() {
timeout = 30
`)
- config, err := Read(invalidConfigPath)
+ config, err := Read(invalidConfigPath, "")
s.Run("returns error for invalid toolset config", func() {
s.Require().NotNil(err, "Expected error for invalid toolset config, got nil")
s.ErrorContains(err, "validation error forced by test", "Expected validation error from toolset config")
@@ -110,7 +110,7 @@ func (s *ToolsetConfigSuite) TestReadConfigUnregisteredToolsetConfig() {
timeout = 30
`)
- config, err := Read(unregisteredConfigPath)
+ config, err := Read(unregisteredConfigPath, "")
s.Run("returns no error for unregistered toolset config", func() {
s.Require().NoError(err, "Expected no error for unregistered toolset config, got %v", err)
})
diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go
index b747cc02..9f5081d9 100644
--- a/pkg/kubernetes-mcp-server/cmd/root.go
+++ b/pkg/kubernetes-mcp-server/cmd/root.go
@@ -10,8 +10,10 @@ import (
"net/http"
"net/url"
"os"
+ "os/signal"
"strconv"
"strings"
+ "syscall"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"
@@ -57,6 +59,7 @@ const (
flagVersion = "version"
flagLogLevel = "log-level"
flagConfig = "config"
+ flagConfigDir = "config-dir"
flagPort = "port"
flagSSEBaseUrl = "sse-base-url"
flagKubeconfig = "kubeconfig"
@@ -92,6 +95,7 @@ type MCPServerOptions struct {
DisableMultiCluster bool
ConfigPath string
+ ConfigDir string
StaticConfig *config.StaticConfig
genericiooptions.IOStreams
@@ -129,6 +133,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().BoolVar(&o.Version, flagVersion, o.Version, "Print version information and quit")
cmd.Flags().IntVar(&o.LogLevel, flagLogLevel, o.LogLevel, "Set the log level (from 0 to 9)")
cmd.Flags().StringVar(&o.ConfigPath, flagConfig, o.ConfigPath, "Path of the config file.")
+ cmd.Flags().StringVar(&o.ConfigDir, flagConfigDir, o.ConfigDir, "Path to drop-in configuration directory (files loaded in lexical order).")
cmd.Flags().StringVar(&o.Port, flagPort, o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)")
cmd.Flags().StringVar(&o.SSEBaseUrl, flagSSEBaseUrl, o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
cmd.Flags().StringVar(&o.Kubeconfig, flagKubeconfig, o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
@@ -155,7 +160,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
func (m *MCPServerOptions) Complete(cmd *cobra.Command) error {
if m.ConfigPath != "" {
- cnf, err := config.Read(m.ConfigPath)
+ cnf, err := config.Read(m.ConfigPath, m.ConfigDir)
if err != nil {
return err
}
@@ -325,12 +330,19 @@ func (m *MCPServerOptions) Run() error {
oidcProvider = provider
}
- mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: m.StaticConfig})
+ mcpServer, err := mcp.NewServer(mcp.Configuration{
+ StaticConfig: m.StaticConfig,
+ })
if err != nil {
return fmt.Errorf("failed to initialize MCP server: %w", err)
}
defer mcpServer.Close()
+ // Set up SIGHUP handler for configuration reload
+ if m.ConfigPath != "" || m.ConfigDir != "" {
+ m.setupSIGHUPHandler(mcpServer)
+ }
+
if m.StaticConfig.Port != "" {
ctx := context.Background()
return internalhttp.Serve(ctx, mcpServer, m.StaticConfig, oidcProvider, httpClient)
@@ -343,3 +355,33 @@ func (m *MCPServerOptions) Run() error {
return nil
}
+
+// setupSIGHUPHandler sets up a signal handler to reload configuration on SIGHUP.
+// This is a blocking call that runs in a separate goroutine.
+func (m *MCPServerOptions) setupSIGHUPHandler(mcpServer *mcp.Server) {
+ sigHupCh := make(chan os.Signal, 1)
+ signal.Notify(sigHupCh, syscall.SIGHUP)
+
+ go func() {
+ for range sigHupCh {
+ klog.V(1).Info("Received SIGHUP signal, reloading configuration...")
+
+ // Reload config from files
+ newConfig, err := config.Read(m.ConfigPath, m.ConfigDir)
+ if err != nil {
+ klog.Errorf("Failed to reload configuration from disk: %v", err)
+ continue
+ }
+
+ // Apply the new configuration to the MCP server
+ if err := mcpServer.ReloadConfiguration(newConfig); err != nil {
+ klog.Errorf("Failed to apply reloaded configuration: %v", err)
+ continue
+ }
+
+ klog.V(1).Info("Configuration reloaded successfully via SIGHUP")
+ }
+ }()
+
+ klog.V(2).Info("SIGHUP handler registered for configuration reload")
+}
diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index d85eb37c..a54d7281 100644
--- a/pkg/kubernetes-mcp-server/cmd/root_test.go
+++ b/pkg/kubernetes-mcp-server/cmd/root_test.go
@@ -76,7 +76,7 @@ func TestConfig(t *testing.T) {
if err == nil {
t.Fatal("Expected error for invalid config path, got nil")
}
- expected := "open invalid-path-to-config.toml: "
+ expected := "failed to load main config file invalid-path-to-config.toml:"
if !strings.HasPrefix(err.Error(), expected) {
t.Fatalf("Expected error to be %s, got %s", expected, err.Error())
}
diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go
index 7d663ef6..9c86598c 100644
--- a/pkg/mcp/mcp.go
+++ b/pkg/mcp/mcp.go
@@ -9,6 +9,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
authenticationapiv1 "k8s.io/api/authentication/v1"
+ "k8s.io/klog/v2"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
@@ -203,6 +204,27 @@ func (s *Server) GetEnabledTools() []string {
return s.enabledTools
}
+// ReloadConfiguration reloads the configuration and reinitializes the server.
+// This is intended to be called by the server lifecycle manager when
+// configuration changes are detected.
+func (s *Server) ReloadConfiguration(newConfig *config.StaticConfig) error {
+ klog.V(1).Info("Reloading MCP server configuration...")
+
+ // Update the configuration
+ s.configuration.StaticConfig = newConfig
+ // Clear cached values so they get recomputed
+ s.configuration.listOutput = nil
+ s.configuration.toolsets = nil
+
+ // Reload the Kubernetes provider (this will also rebuild tools)
+ if err := s.reloadToolsets(); err != nil {
+ return fmt.Errorf("failed to reload toolsets: %w", err)
+ }
+
+ klog.V(1).Info("MCP server configuration reloaded successfully")
+ return nil
+}
+
func (s *Server) Close() {
if s.p != nil {
s.p.Close()
diff --git a/pkg/mcp/mcp_reload_test.go b/pkg/mcp/mcp_reload_test.go
new file mode 100644
index 00000000..ddb8f958
--- /dev/null
+++ b/pkg/mcp/mcp_reload_test.go
@@ -0,0 +1,233 @@
+package mcp
+
+import (
+ "testing"
+
+ "github.com/containers/kubernetes-mcp-server/internal/test"
+ "github.com/containers/kubernetes-mcp-server/pkg/config"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/stretchr/testify/suite"
+)
+
+type ConfigReloadSuite struct {
+ BaseMcpSuite
+ mockServer *test.MockServer
+ server *Server
+}
+
+func (s *ConfigReloadSuite) SetupTest() {
+ s.BaseMcpSuite.SetupTest()
+ s.mockServer = test.NewMockServer()
+ s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
+ s.mockServer.Handle(&test.DiscoveryClientHandler{})
+}
+
+func (s *ConfigReloadSuite) TearDownTest() {
+ s.BaseMcpSuite.TearDownTest()
+ if s.server != nil {
+ s.server.Close()
+ }
+ if s.mockServer != nil {
+ s.mockServer.Close()
+ }
+}
+
+func (s *ConfigReloadSuite) TestConfigurationReload() {
+ // Initialize server with initial config
+ server, err := NewServer(Configuration{
+ StaticConfig: s.Cfg,
+ })
+ s.Require().NoError(err)
+ s.Require().NotNil(server)
+ s.server = server
+
+ s.Run("initial configuration loaded correctly", func() {
+ s.Equal(s.Cfg.LogLevel, server.configuration.LogLevel)
+ s.Equal(s.Cfg.ListOutput, server.configuration.StaticConfig.ListOutput)
+ s.Equal(s.Cfg.Toolsets, server.configuration.StaticConfig.Toolsets)
+ })
+
+ s.Run("reload with new log level", func() {
+ newConfig := config.Default()
+ newConfig.LogLevel = 5
+ newConfig.ListOutput = "yaml"
+ newConfig.Toolsets = []string{"core", "config"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ s.Equal(5, server.configuration.LogLevel)
+ s.Equal("yaml", server.configuration.StaticConfig.ListOutput)
+ s.Equal([]string{"core", "config"}, server.configuration.StaticConfig.Toolsets)
+ })
+
+ s.Run("reload with additional toolsets", func() {
+ newConfig := config.Default()
+ newConfig.LogLevel = 5
+ newConfig.ListOutput = "yaml"
+ newConfig.Toolsets = []string{"core", "config", "helm"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ s.Equal(5, server.configuration.LogLevel)
+ s.Equal("yaml", server.configuration.StaticConfig.ListOutput)
+ s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets)
+ })
+
+ s.Run("reload with partial changes", func() {
+ newConfig := config.Default()
+ newConfig.LogLevel = 7
+ newConfig.ListOutput = "yaml"
+ newConfig.Toolsets = []string{"core", "config", "helm"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ s.Equal(7, server.configuration.LogLevel)
+ s.Equal("yaml", server.configuration.StaticConfig.ListOutput)
+ s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets)
+ })
+
+ s.Run("reload back to defaults", func() {
+ newConfig := config.Default()
+ newConfig.LogLevel = 0
+ newConfig.ListOutput = "table"
+ newConfig.Toolsets = []string{"core", "config"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ s.Equal(0, server.configuration.LogLevel)
+ s.Equal("table", server.configuration.StaticConfig.ListOutput)
+ s.Equal([]string{"core", "config"}, server.configuration.StaticConfig.Toolsets)
+ })
+}
+
+func (s *ConfigReloadSuite) TestConfigurationValues() {
+ server, err := NewServer(Configuration{
+ StaticConfig: s.Cfg,
+ })
+ s.Require().NoError(err)
+ s.server = server
+
+ s.Run("reload updates configuration values", func() {
+ // Verify initial values
+ initialLogLevel := server.configuration.LogLevel
+
+ newConfig := config.Default()
+ newConfig.LogLevel = 9
+ newConfig.ListOutput = "yaml"
+ newConfig.Toolsets = []string{"core", "config", "helm"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ // Verify configuration was updated
+ s.NotEqual(initialLogLevel, server.configuration.LogLevel)
+ s.Equal(9, server.configuration.LogLevel)
+ s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets)
+ s.Equal("yaml", server.configuration.StaticConfig.ListOutput)
+ })
+}
+
+func (s *ConfigReloadSuite) TestMultipleReloads() {
+ server, err := NewServer(Configuration{
+ StaticConfig: s.Cfg,
+ })
+ s.Require().NoError(err)
+ s.server = server
+
+ s.Run("multiple reloads in succession", func() {
+ // First reload
+ cfg1 := config.Default()
+ cfg1.LogLevel = 3
+ cfg1.KubeConfig = s.Cfg.KubeConfig
+ cfg1.Toolsets = []string{"core"}
+ err = server.ReloadConfiguration(cfg1)
+ s.Require().NoError(err)
+ s.Equal(3, server.configuration.LogLevel)
+
+ // Second reload
+ cfg2 := config.Default()
+ cfg2.LogLevel = 6
+ cfg2.KubeConfig = s.Cfg.KubeConfig
+ cfg2.Toolsets = []string{"core", "config"}
+ err = server.ReloadConfiguration(cfg2)
+ s.Require().NoError(err)
+ s.Equal(6, server.configuration.LogLevel)
+
+ // Third reload
+ cfg3 := config.Default()
+ cfg3.LogLevel = 9
+ cfg3.KubeConfig = s.Cfg.KubeConfig
+ cfg3.Toolsets = []string{"core", "config", "helm"}
+ err = server.ReloadConfiguration(cfg3)
+ s.Require().NoError(err)
+ s.Equal(9, server.configuration.LogLevel)
+ })
+}
+
+func (s *ConfigReloadSuite) TestReloadUpdatesToolsets() {
+ server, err := NewServer(Configuration{
+ StaticConfig: s.Cfg,
+ })
+ s.Require().NoError(err)
+ s.server = server
+
+ // Get initial tools
+ s.InitMcpClient()
+ initialTools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
+ s.Require().NoError(err)
+ s.Require().Greater(len(initialTools.Tools), 0)
+
+ // Add helm toolset via reload
+ newConfig := config.Default()
+ newConfig.Toolsets = []string{"core", "config", "helm"}
+ newConfig.KubeConfig = s.Cfg.KubeConfig
+
+ // Reload configuration
+ err = server.ReloadConfiguration(newConfig)
+ s.Require().NoError(err)
+
+ // Verify helm tools are available
+ reloadedTools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
+ s.Require().NoError(err)
+
+ helmToolFound := false
+ for _, tool := range reloadedTools.Tools {
+ if tool.Name == "helm_list" {
+ helmToolFound = true
+ break
+ }
+ }
+ s.True(helmToolFound, "helm tools should be available after reload")
+}
+
+func (s *ConfigReloadSuite) TestServerLifecycle() {
+ server, err := NewServer(Configuration{
+ StaticConfig: s.Cfg,
+ })
+ s.Require().NoError(err)
+
+ s.Run("server closes without panic", func() {
+ s.NotPanics(func() {
+ server.Close()
+ })
+ })
+
+ s.Run("double close does not panic", func() {
+ s.NotPanics(func() {
+ server.Close()
+ })
+ })
+}
+
+func TestConfigReload(t *testing.T) {
+ suite.Run(t, new(ConfigReloadSuite))
+}