From 8b5f7c419177276a4d2924330cb6d1d39f57437b Mon Sep 17 00:00:00 2001 From: Nader Ziada Date: Fri, 28 Nov 2025 15:36:49 -0500 Subject: [PATCH] feat(config): add drop-in configuration support with dynamic reload Add support for flexible configuration management through both main config files and drop-in directories - Load config from --config (main file) and --config-dir (drop-in directory) - Drop-in files processed in alphabetical order for predictable overrides - Partial configuration support - drop-in files can override specific values - Dynamic configuration reload via SIGHUP signal - SIGHUP support requires either --config or --config-dir flag at startup Signed-off-by: Nader Ziada --- README.md | 90 ++++++++ pkg/config/config.go | 151 ++++++++++++- pkg/config/config_test.go | 229 +++++++++++++++++--- pkg/config/provider_config_test.go | 10 +- pkg/config/toolset_config_test.go | 6 +- pkg/kubernetes-mcp-server/cmd/root.go | 46 +++- pkg/kubernetes-mcp-server/cmd/root_test.go | 2 +- pkg/mcp/mcp.go | 22 ++ pkg/mcp/mcp_reload_test.go | 233 +++++++++++++++++++++ 9 files changed, 734 insertions(+), 55 deletions(-) create mode 100644 pkg/mcp/mcp_reload_test.go 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)) +}