From d1d9bd291b8392aac8b52c765b4a91269bc813cb Mon Sep 17 00:00:00 2001 From: Nader Ziada Date: Fri, 21 Nov 2025 10:02:20 -0500 Subject: [PATCH 1/2] feat(kubernetes): add cluster state monitoring with debounced reload Implement automatic detection of cluster state changes (API groups, OpenShift status) with configurable polling and debounce windows. The cluster state watcher runs in the background, invalidates the discovery cache periodically, and triggers a reload callback when changes are detected after a debounce period. - Add clusterStateWatcher to monitor API groups and OpenShift status - Implement debounced reload to avoid excessive reloads during changes - Add WatchClusterState method to Manager with configurable intervals - Integrate cluster state watching in kubeconfig and single cluster providers Signed-off-by: Nader Ziada --- pkg/kubernetes/manager.go | 148 ++++++++++++++++++++++++++ pkg/kubernetes/manager_test.go | 42 ++++++++ pkg/kubernetes/provider_kubeconfig.go | 2 +- pkg/kubernetes/provider_single.go | 1 + pkg/mcp/mcp.go | 34 ++++-- 5 files changed, 215 insertions(+), 12 deletions(-) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index 32bd278e..df990bf2 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "sort" "strings" + "sync" + "time" "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/helm" @@ -25,11 +28,39 @@ type Manager struct { staticConfig *config.StaticConfig CloseWatchKubeConfig CloseWatchKubeConfig + + clusterWatcher *clusterStateWatcher +} + +// clusterState represents the cached state of the cluster +type clusterState struct { + apiGroups []string + isOpenShift bool +} + +// clusterStateWatcher monitors cluster state changes and triggers debounced reloads +type clusterStateWatcher struct { + manager *Manager + pollInterval time.Duration + debounceWindow time.Duration + lastKnownState clusterState + reloadCallback func() error + debounceTimer *time.Timer + mu sync.Mutex + stopCh chan struct{} + stoppedCh chan struct{} } var _ helm.Kubernetes = (*Manager)(nil) var _ Openshift = (*Manager)(nil) +const ( + // DefaultClusterStatePollInterval is the default interval for polling cluster state changes + DefaultClusterStatePollInterval = 30 * time.Second + // DefaultClusterStateDebounceWindow is the default debounce window for cluster state changes + DefaultClusterStateDebounceWindow = 5 * time.Second +) + var ( ErrorKubeconfigInClusterNotAllowed = errors.New("kubeconfig manager cannot be used in in-cluster deployments") ErrorInClusterNotInCluster = errors.New("in-cluster manager cannot be used outside of a cluster") @@ -148,6 +179,9 @@ func (m *Manager) Close() { if m.CloseWatchKubeConfig != nil { _ = m.CloseWatchKubeConfig() } + if m.clusterWatcher != nil { + m.clusterWatcher.stop() + } } func (m *Manager) configuredNamespace() string { @@ -263,3 +297,117 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } return derived, nil } + +// WatchClusterState starts a background watcher that periodically polls for cluster state changes +// and triggers a debounced reload when changes are detected. +func (m *Manager) WatchClusterState(pollInterval, debounceWindow time.Duration, onClusterStateChange func() error) { + if m.clusterWatcher != nil { + m.clusterWatcher.stop() + } + + watcher := &clusterStateWatcher{ + manager: m, + pollInterval: pollInterval, + debounceWindow: debounceWindow, + reloadCallback: onClusterStateChange, + stopCh: make(chan struct{}), + stoppedCh: make(chan struct{}), + } + + captureState := func() clusterState { + state := clusterState{apiGroups: []string{}} + if groups, err := m.discoveryClient.ServerGroups(); err == nil { + for _, group := range groups.Groups { + state.apiGroups = append(state.apiGroups, group.Name) + } + sort.Strings(state.apiGroups) + } + state.isOpenShift = m.IsOpenShift(context.Background()) + return state + } + watcher.lastKnownState = captureState() + + m.clusterWatcher = watcher + + // Start background monitoring + go func() { + defer close(watcher.stoppedCh) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + klog.V(2).Infof("Started cluster state watcher (poll interval: %v, debounce: %v)", pollInterval, debounceWindow) + + for { + select { + case <-watcher.stopCh: + klog.V(2).Info("Stopping cluster state watcher") + return + case <-ticker.C: + // Invalidate discovery cache to get fresh API groups + m.discoveryClient.Invalidate() + + watcher.mu.Lock() + current := captureState() + klog.V(3).Infof("Polled cluster state: %d API groups, OpenShift=%v", len(current.apiGroups), current.isOpenShift) + + changed := current.isOpenShift != watcher.lastKnownState.isOpenShift || + len(current.apiGroups) != len(watcher.lastKnownState.apiGroups) + + if !changed { + for i := range current.apiGroups { + if current.apiGroups[i] != watcher.lastKnownState.apiGroups[i] { + changed = true + break + } + } + } + + if changed { + klog.V(2).Info("Cluster state changed, scheduling debounced reload") + if watcher.debounceTimer != nil { + watcher.debounceTimer.Stop() + } + watcher.debounceTimer = time.AfterFunc(debounceWindow, func() { + klog.V(2).Info("Debounce window expired, triggering reload") + if err := onClusterStateChange(); err != nil { + klog.Errorf("Failed to reload: %v", err) + } else { + watcher.mu.Lock() + watcher.lastKnownState = captureState() + watcher.mu.Unlock() + klog.V(2).Info("Reload completed") + } + }) + } + watcher.mu.Unlock() + } + } + }() +} + +// stop stops the cluster state watcher +func (w *clusterStateWatcher) stop() { + if w == nil { + return + } + + w.mu.Lock() + defer w.mu.Unlock() + + if w.debounceTimer != nil { + w.debounceTimer.Stop() + } + + if w.stopCh == nil || w.stoppedCh == nil { + return + } + + select { + case <-w.stopCh: + // Already closed or stopped + return + default: + close(w.stopCh) + <-w.stoppedCh + } +} diff --git a/pkg/kubernetes/manager_test.go b/pkg/kubernetes/manager_test.go index c6f9da6a..843995ed 100644 --- a/pkg/kubernetes/manager_test.go +++ b/pkg/kubernetes/manager_test.go @@ -197,6 +197,48 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { }) } +func (s *ManagerTestSuite) TestClusterStateWatcherStop() { + s.Run("stop() on nil watcher", func() { + var watcher *clusterStateWatcher + // Should not panic + watcher.stop() + }) + + s.Run("stop() on uninitialized watcher (nil channels)", func() { + watcher := &clusterStateWatcher{} + // Should not panic even with nil channels + watcher.stop() + }) + + s.Run("stop() on initialized watcher", func() { + watcher := &clusterStateWatcher{ + stopCh: make(chan struct{}), + stoppedCh: make(chan struct{}), + } + // Close the stoppedCh to simulate a running goroutine + go func() { + <-watcher.stopCh + close(watcher.stoppedCh) + }() + // Should not panic and should stop cleanly + watcher.stop() + }) + + s.Run("stop() called multiple times", func() { + watcher := &clusterStateWatcher{ + stopCh: make(chan struct{}), + stoppedCh: make(chan struct{}), + } + go func() { + <-watcher.stopCh + close(watcher.stoppedCh) + }() + // First stop + watcher.stop() + // Second stop should not panic + watcher.stop() + }) +} func TestManager(t *testing.T) { suite.Run(t, new(ManagerTestSuite)) } diff --git a/pkg/kubernetes/provider_kubeconfig.go b/pkg/kubernetes/provider_kubeconfig.go index 9ab055c8..2fbf5c09 100644 --- a/pkg/kubernetes/provider_kubeconfig.go +++ b/pkg/kubernetes/provider_kubeconfig.go @@ -120,8 +120,8 @@ func (p *kubeConfigClusterProvider) GetDefaultTarget() string { func (p *kubeConfigClusterProvider) WatchTargets(onKubeConfigChanged func() error) { m := p.managers[p.defaultContext] - m.WatchKubeConfig(onKubeConfigChanged) + m.WatchClusterState(DefaultClusterStatePollInterval, DefaultClusterStateDebounceWindow, onKubeConfigChanged) } func (p *kubeConfigClusterProvider) Close() { diff --git a/pkg/kubernetes/provider_single.go b/pkg/kubernetes/provider_single.go index 3693d639..1e663f67 100644 --- a/pkg/kubernetes/provider_single.go +++ b/pkg/kubernetes/provider_single.go @@ -87,6 +87,7 @@ func (p *singleClusterProvider) GetTargetParameterName() string { func (p *singleClusterProvider) WatchTargets(watch func() error) { p.manager.WatchKubeConfig(watch) + p.manager.WatchClusterState(DefaultClusterStatePollInterval, DefaultClusterStateDebounceWindow, watch) } func (p *singleClusterProvider) Close() { diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 6a4a6d2f..8fee520f 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -98,31 +98,44 @@ func NewServer(configuration Configuration) (*Server, error) { func (s *Server) reloadKubernetesClusterProvider() error { ctx := context.Background() - p, err := internalk8s.NewProvider(s.configuration.StaticConfig) + + newProvider, err := internalk8s.NewProvider(s.configuration.StaticConfig) + if err != nil { + return err + } + + targets, err := newProvider.GetTargets(ctx) if err != nil { + newProvider.Close() return err } - // close the old provider if s.p != nil { s.p.Close() } - s.p = p + s.p = newProvider - targets, err := p.GetTargets(ctx) - if err != nil { + if err := s.rebuildTools(targets); err != nil { return err } + s.p.WatchTargets(s.reloadKubernetesClusterProvider) + + return nil +} + +// rebuildTools rebuilds the MCP tool registry based on the current provider and targets. +// This is called after the provider has been successfully validated and set. +func (s *Server) rebuildTools(targets []string) error { filter := CompositeFilter( s.configuration.isToolApplicable, - ShouldIncludeTargetListTool(p.GetTargetParameterName(), targets), + ShouldIncludeTargetListTool(s.p.GetTargetParameterName(), targets), ) mutator := WithTargetParameter( - p.GetDefaultTarget(), - p.GetTargetParameterName(), + s.p.GetDefaultTarget(), + s.p.GetTargetParameterName(), targets, ) @@ -136,7 +149,7 @@ func (s *Server) reloadKubernetesClusterProvider() error { applicableTools := make([]api.ServerTool, 0) s.enabledTools = make([]string, 0) for _, toolset := range s.configuration.Toolsets() { - for _, tool := range toolset.GetTools(p) { + for _, tool := range toolset.GetTools(s.p) { tool := mutator(tool) if !filter(tool) { continue @@ -157,6 +170,7 @@ func (s *Server) reloadKubernetesClusterProvider() error { } s.server.RemoveTools(toolsToRemove...) + // Add new tools for _, tool := range applicableTools { goSdkTool, goSdkToolHandler, err := ServerToolToGoSdkTool(s, tool) if err != nil { @@ -165,8 +179,6 @@ func (s *Server) reloadKubernetesClusterProvider() error { s.server.AddTool(goSdkTool, goSdkToolHandler) } - // start new watch - s.p.WatchTargets(s.reloadKubernetesClusterProvider) return nil } From f54d30e2f3763daa4c36b1838805fd9b225be649 Mon Sep 17 00:00:00 2001 From: Nader Ziada Date: Fri, 21 Nov 2025 15:47:53 -0500 Subject: [PATCH 2/2] feat(config): add drop-in config support and SIGHUP reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drop-in configuration directory support with lexical ordering - Implement SIGHUP signal handler for live config reloads - Merge config files in order: defaults → main → drop-ins - Add --config-dir flag for specifying drop-in directory Signed-off-by: Nader Ziada --- README.md | 84 ++++++ pkg/config/config.go | 146 +++++++++- pkg/config/config_test.go | 229 ++++++++++++--- pkg/config/provider_config_test.go | 10 +- pkg/kubernetes-mcp-server/cmd/root.go | 11 +- pkg/kubernetes-mcp-server/cmd/root_test.go | 2 +- pkg/mcp/mcp.go | 63 ++++ pkg/mcp/mcp_reload_test.go | 318 +++++++++++++++++++++ 8 files changed, 811 insertions(+), 52 deletions(-) create mode 100644 pkg/mcp/mcp_reload_test.go diff --git a/README.md b/README.md index 97317a30..a7c38303 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 [Configuration Files](#configuration-files) 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,88 @@ 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: + +```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 toolsets and enabled tools +- Reconnect to clusters if needed +- Log the reload status + +**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 20695768..b0f65dc7 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 ( @@ -95,26 +98,147 @@ 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 != "" { + 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) + } + + for _, opt := range opts { + opt(cfg) + } + + if err := cfg.parseClusterProviderConfigs(md); err != nil { + return err + } + + if err := cfg.parseToolsetConfigs(md); err != nil { + return err } - dirPath := filepath.Dir(absPath) - cfg, err := ReadToml(configData, append(opts, withDirPath(dirPath))...) + 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 { - return nil, err + 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) } - return cfg, nil + 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 afdde191..4e7bafd2 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") @@ -151,7 +151,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") @@ -174,46 +174,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/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 0a1c6029..d1fc5259 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -57,6 +57,7 @@ const ( flagVersion = "version" flagLogLevel = "log-level" flagConfig = "config" + flagConfigDir = "config-dir" flagPort = "port" flagSSEBaseUrl = "sse-base-url" flagKubeconfig = "kubeconfig" @@ -92,6 +93,7 @@ type MCPServerOptions struct { DisableMultiCluster bool ConfigPath string + ConfigDir string StaticConfig *config.StaticConfig genericiooptions.IOStreams @@ -129,6 +131,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 +158,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 } @@ -319,7 +322,11 @@ 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, + ConfigPath: m.ConfigPath, + ConfigDir: m.ConfigDir, + }) if err != nil { return fmt.Errorf("failed to initialize MCP server: %w", err) } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index a464daab..5e21a2f2 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 8fee520f..cd201b62 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -5,10 +5,13 @@ import ( "fmt" "net/http" "os" + "os/signal" "slices" + "syscall" "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" @@ -25,6 +28,8 @@ const TokenScopesContextKey = ContextKey("TokenScopesContextKey") type Configuration struct { *config.StaticConfig + ConfigPath string // Path to main config file (for watching) + ConfigDir string // Path to drop-in config directory (for watching) listOutput output.Output toolsets []api.Toolset } @@ -66,6 +71,7 @@ type Server struct { server *mcp.Server enabledTools []string p internalk8s.Provider + sigHupCh chan os.Signal } func NewServer(configuration Configuration) (*Server, error) { @@ -93,6 +99,9 @@ func NewServer(configuration Configuration) (*Server, error) { } s.p.WatchTargets(s.reloadKubernetesClusterProvider) + // Set up SIGHUP handler for configuration reload + s.setupSIGHUPHandler() + return s, nil } @@ -223,7 +232,61 @@ func (s *Server) GetEnabledTools() []string { return s.enabledTools } +// setupSIGHUPHandler sets up a signal handler to reload configuration on SIGHUP +func (s *Server) setupSIGHUPHandler() { + s.sigHupCh = make(chan os.Signal, 1) + signal.Notify(s.sigHupCh, syscall.SIGHUP) + + go func() { + for range s.sigHupCh { + klog.V(1).Info("Received SIGHUP signal") + if err := s.reloadConfiguration(); err != nil { + klog.Errorf("Failed to reload configuration: %v", err) + } else { + klog.V(1).Info("Configuration reloaded successfully via SIGHUP") + } + } + }() + + klog.V(2).Info("SIGHUP handler registered for configuration reload") +} + +// reloadConfiguration reloads the configuration from disk and reinitializes the server +func (s *Server) reloadConfiguration() error { + klog.V(1).Info("Reloading configuration...") + + // Reload config from files + newConfig, err := config.Read(s.configuration.ConfigPath, s.configuration.ConfigDir) + if err != nil { + return fmt.Errorf("failed to reload configuration: %w", err) + } + + // 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.reloadKubernetesClusterProvider(); err != nil { + return fmt.Errorf("failed to reload Kubernetes provider: %w", err) + } + + klog.V(1).Info("Configuration reloaded successfully") + return nil +} + func (s *Server) Close() { + if s.sigHupCh != nil { + signal.Stop(s.sigHupCh) + // Check if channel is already closed + select { + case <-s.sigHupCh: + // Already closed + default: + close(s.sigHupCh) + } + } 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..de3ec160 --- /dev/null +++ b/pkg/mcp/mcp_reload_test.go @@ -0,0 +1,318 @@ +package mcp + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "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 + configFile string + configDir string + 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{}) + + tempDir := s.T().TempDir() + s.configFile = filepath.Join(tempDir, "config.toml") + s.configDir = filepath.Join(tempDir, "config.d") + err := os.Mkdir(s.configDir, 0755) + s.Require().NoError(err) + + // Write initial config (include kubeconfig so reload works) + err = os.WriteFile(s.configFile, []byte(` +log_level = 1 +list_output = "table" +toolsets = ["core", "config"] +kubeconfig = "`+s.Cfg.KubeConfig+`" +`), 0644) + s.Require().NoError(err) +} + +func (s *ConfigReloadSuite) TearDownTest() { + s.BaseMcpSuite.TearDownTest() + if s.server != nil { + s.server.Close() + } + if s.mockServer != nil { + s.mockServer.Close() + } +} + +func (s *ConfigReloadSuite) TestDropInConfigurationReload() { + // Initialize server - it will load from config files + cfg, err := config.Read(s.configFile, s.configDir) + s.Require().NoError(err) + server, err := NewServer(Configuration{ + StaticConfig: cfg, + ConfigPath: s.configFile, + ConfigDir: s.configDir, + }) + s.Require().NoError(err) + s.Require().NotNil(server) + s.server = server + + s.Run("initial configuration loaded correctly", func() { + s.Equal(1, server.configuration.LogLevel) + s.Equal("table", server.configuration.StaticConfig.ListOutput) + s.Equal([]string{"core", "config"}, server.configuration.StaticConfig.Toolsets) + }) + + // Add first drop-in file + dropIn1 := filepath.Join(s.configDir, "10-override.toml") + err = os.WriteFile(dropIn1, []byte(` +log_level = 5 +list_output = "yaml" +`), 0644) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + + s.Run("drop-in file overrides main config", func() { + s.Equal(5, server.configuration.LogLevel) + s.Equal("yaml", server.configuration.StaticConfig.ListOutput) + s.Equal([]string{"core", "config"}, server.configuration.StaticConfig.Toolsets) + }) + + // Add second drop-in file with different priority + dropIn2 := filepath.Join(s.configDir, "20-toolsets.toml") + err = os.WriteFile(dropIn2, []byte(` +toolsets = ["core", "config", "helm"] +`), 0644) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + + s.Run("multiple drop-ins with correct precedence", func() { + s.Equal(5, server.configuration.LogLevel) + s.Equal("yaml", server.configuration.StaticConfig.ListOutput) + s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets) + }) + + // Add third drop-in that partially overrides + dropIn3 := filepath.Join(s.configDir, "30-partial.toml") + err = os.WriteFile(dropIn3, []byte(` +log_level = 7 +`), 0644) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + + s.Run("later drop-in overrides earlier with partial config", func() { + s.Equal(7, server.configuration.LogLevel) + s.Equal("yaml", server.configuration.StaticConfig.ListOutput) + s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets) + }) + + // Remove all drop-in files to test empty directory + err = os.Remove(dropIn1) + s.Require().NoError(err) + err = os.Remove(dropIn2) + s.Require().NoError(err) + err = os.Remove(dropIn3) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + + s.Run("empty drop-in directory reverts to main config", func() { + s.Equal(1, server.configuration.LogLevel) + s.Equal("table", server.configuration.StaticConfig.ListOutput) + s.Equal([]string{"core", "config"}, server.configuration.StaticConfig.Toolsets) + }) + + // Add a drop-in and then remove it + tempDropIn := filepath.Join(s.configDir, "10-temp.toml") + err = os.WriteFile(tempDropIn, []byte(` +log_level = 8 +`), 0644) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + s.Equal(8, server.configuration.LogLevel) + + err = os.Remove(tempDropIn) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Require().NoError(err) + + s.Run("removing drop-in file reverts to main config", func() { + s.Equal(1, server.configuration.LogLevel) + }) +} + +func (s *ConfigReloadSuite) TestConfigurationReloadErrors() { + server, err := NewServer(Configuration{ + StaticConfig: s.Cfg, + ConfigPath: s.configFile, + ConfigDir: s.configDir, + }) + s.Require().NoError(err) + s.server = server + + initialLogLevel := server.configuration.LogLevel + + s.Run("invalid TOML in drop-in file", func() { + dropIn := filepath.Join(s.configDir, "10-invalid.toml") + err = os.WriteFile(dropIn, []byte(` +log_level = "invalid +`), 0644) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Error(err, "should return error for invalid TOML") + s.Equal(initialLogLevel, server.configuration.LogLevel, "config unchanged on error") + + // Cleanup + _ = os.Remove(dropIn) + }) + + s.Run("missing main config file", func() { + // Delete main config file + err = os.Remove(s.configFile) + s.Require().NoError(err) + + err = server.reloadConfiguration() + s.Error(err, "should return error for missing config file") + s.Equal(initialLogLevel, server.configuration.LogLevel, "config unchanged on error") + }) +} + +func (s *ConfigReloadSuite) TestSIGHUPReload() { + server, err := NewServer(Configuration{ + StaticConfig: s.Cfg, + ConfigPath: s.configFile, + ConfigDir: s.configDir, + }) + s.Require().NoError(err) + s.server = server + + initialLogLevel := server.configuration.LogLevel + + s.Run("single SIGHUP triggers reload", func() { + dropIn := filepath.Join(s.configDir, "10-sighup.toml") + err = os.WriteFile(dropIn, []byte(` +log_level = 9 +`), 0644) + s.Require().NoError(err) + + // Send SIGHUP signal to the channel + server.sigHupCh <- syscall.SIGHUP + time.Sleep(100 * time.Millisecond) + + s.NotEqual(initialLogLevel, server.configuration.LogLevel) + s.Equal(9, server.configuration.LogLevel) + + // Cleanup for next test + _ = os.Remove(dropIn) + }) + + s.Run("multiple SIGHUP signals in succession", func() { + dropIn := filepath.Join(s.configDir, "10-multi.toml") + + // First SIGHUP + err = os.WriteFile(dropIn, []byte(`log_level = 3`), 0644) + s.Require().NoError(err) + server.sigHupCh <- syscall.SIGHUP + time.Sleep(50 * time.Millisecond) + s.Equal(3, server.configuration.LogLevel) + + // Second SIGHUP + err = os.WriteFile(dropIn, []byte(`log_level = 6`), 0644) + s.Require().NoError(err) + server.sigHupCh <- syscall.SIGHUP + time.Sleep(50 * time.Millisecond) + s.Equal(6, server.configuration.LogLevel) + + // Third SIGHUP + err = os.WriteFile(dropIn, []byte(`log_level = 9`), 0644) + s.Require().NoError(err) + server.sigHupCh <- syscall.SIGHUP + time.Sleep(50 * time.Millisecond) + s.Equal(9, server.configuration.LogLevel) + }) +} + +func (s *ConfigReloadSuite) TestReloadUpdatesToolsets() { + server, err := NewServer(Configuration{ + StaticConfig: s.Cfg, + ConfigPath: s.configFile, + ConfigDir: s.configDir, + }) + 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 drop-in + dropIn := filepath.Join(s.configDir, "10-add-helm.toml") + err = os.WriteFile(dropIn, []byte(` +toolsets = ["core", "config", "helm"] +`), 0644) + s.Require().NoError(err) + + // Reload configuration + err = server.reloadConfiguration() + 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, + ConfigPath: s.configFile, + ConfigDir: s.configDir, + }) + 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)) +}