Repository: containers/kubernetes-mcp-server

Issue:
[CONFIG] Add drop-in configuration support with conf.d directory
## Description

### Background

Many Linux system services follow a convention of supporting drop-in configuration directories (commonly named `conf.d/` or `*.d/`) that allow users to extend or override base configuration without modifying the main configuration file. This pattern is widely used in:

- **systemd** - Uses `*.conf.d/` directories for unit file overrides
- **CRI-O** - Supports `/etc/crio/crio.conf.d/` for container runtime configuration
- **kubelet** - Supports drop-in configurations for flexible node customization

This approach provides several benefits:
- Clean separation between default and custom configurations
- Easy configuration management in containerized/orchestrated environments
- Predictable override behavior through alphabetical ordering
- Simplified package management (packages can drop configs without conflicts)

### Feature Request

Add support for drop-in configuration directories to kubernetes-mcp-server, allowing users to:

1. Automatically load drop-in configurations from a `conf.d/` directory relative to the main config file
2. Optionally specify a custom configuration directory via `--config-dir` flag
3. Load partial configuration files from the directory in alphabetical order
4. Merge drop-in configurations with the main config file (`--config`)
5. Dynamically reload configuration via SIGHUP signal when using config flags

### Use Cases

1. **Container deployments**: Mount ConfigMaps as individual files in a conf.d directory
2. **Package distribution**: Ship default configs that users can override without editing
3. **Environment-specific overrides**: Layer environment-specific settings on top of base config
4. **Feature flags**: Enable/disable specific toolsets via separate drop-in files

## Proposed Implementation

### CLI Flags

```bash
# Load main config + automatically load drop-ins from conf.d/ relative to config file
# (e.g., /etc/kmcp/conf.d/ when config is /etc/kmcp/config.toml)
kubernetes-mcp-server --config /etc/kmcp/config.toml

# Load main config + drop-ins from custom directory (overrides default conf.d)
kubernetes-mcp-server --config /etc/kmcp/config.toml --config-dir /custom/conf.d/

# Load only from drop-in directory (no main config)
kubernetes-mcp-server --config-dir /etc/kmcp/conf.d/
```

### Default Behavior

When `--config` is specified:
- The `--config-dir` defaults to `conf.d/` within the same directory as the config file
- Example: `--config /etc/kmcp/config.toml` → config-dir defaults to `/etc/kmcp/conf.d/`
- This can be overridden by explicitly setting `--config-dir`

### Processing Order

1. Load main config file (if `--config` specified)
2. Determine config-dir (explicit `--config-dir` or default `conf.d/` relative to config file)
3. Scan config-dir for `*.toml` files (if directory exists)
4. Sort files alphabetically
5. Merge each drop-in file in order (later files override earlier ones)

### Extended Configuration Merging

Extended configurations (`config.Extended`) must be properly merged when scattered across multiple drop-in files. For example:

```toml
# conf.d/10-base-extended.toml
[extended.myextension]
setting1 = "value1"

# conf.d/20-override-extended.toml
[extended.myextension]
setting2 = "value2"

# conf.d/30-another-extension.toml
[extended.otherextension]
enabled = true
```

The merged result should contain both extensions with their respective settings properly combined.

### Example Directory Structure

```
/etc/kmcp/
├── config.toml              # Main configuration
└── conf.d/                  # Default drop-in directory (auto-discovered)
    ├── 00-base.toml         # Base settings
    ├── 10-toolsets.toml     # Toolset configuration
    ├── 20-security.toml     # Security settings
    ├── 30-extended.toml     # Extended configuration
    └── 99-overrides.toml    # Final overrides
```

### Dynamic Reload

When `--config` or `--config-dir` is specified:
- The server should handle SIGHUP signal
- On SIGHUP, reload and re-merge all configuration files
- Apply new configuration without server restart

## Acceptance Criteria

- [ ] When `--config` is specified, `--config-dir` defaults to `conf.d/` in the same directory as the config file
- [ ] New `--config-dir` flag accepts a path to override the default configuration directory
- [ ] Drop-in files (`*.toml`) are processed in alphabetical order
- [ ] Drop-in configurations can contain partial settings (only override specific values)
- [ ] Extended configurations (`config.Extended`) are properly merged when scattered across multiple drop-in files
- [ ] Main config (`--config`) and drop-in directory (`--config-dir`) can be used together or independently
- [ ] If the default `conf.d/` directory doesn't exist, the server continues without error
- [ ] SIGHUP signal triggers configuration reload when config flags are used
- [ ] Configuration changes are applied dynamically without restart
- [ ] Documentation is updated with examples and best practices

## Tests

- [ ] Unit tests for drop-in file discovery and ordering
- [ ] Unit tests for configuration merging logic
- [ ] Unit tests for default conf.d directory resolution
- [ ] Unit tests for extended configuration merging across multiple drop-in files
- [ ] Integration tests for SIGHUP reload behavior
- [ ] Edge case tests: empty directory, missing conf.d directory, invalid TOML files, conflicting settings

## Additional Information

- Related PR: #524
- References:
  - [systemd drop-in documentation](https://www.freedesktop.org/software/systemd/man/systemd.unit.html#Description)
  - [CRI-O configuration](https://github.com/cri-o/cri-o/blob/main/docs/crio.conf.5.md)

Hints from issue comments:

Solution patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index cde0864..3e65f54 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,3 +5,8 @@ updates:
     schedule:
       interval: "daily"
     open-pull-requests-limit: 10
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "daily"
+    open-pull-requests-limit: 10
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 469a633..265e4cc 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -41,8 +41,8 @@ jobs:
     runs-on: ${{ matrix.os }}
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
-      - uses: actions/setup-go@v5
+        uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
         with:
           go-version: ${{ env.GO_VERSION }}
       - name: Build
diff --git a/.github/workflows/gevals.yaml b/.github/workflows/gevals.yaml
index f05804b..9af1673 100644
--- a/.github/workflows/gevals.yaml
+++ b/.github/workflows/gevals.yaml
@@ -87,12 +87,12 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
         with:
           ref: ${{ needs.check-trigger.outputs.pr-ref }}
 
       - name: Setup Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@v6
         with:
           go-version: ${{ env.GO_VERSION }}
 
diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml
index eac070e..149eaa7 100644
--- a/.github/workflows/release-image.yml
+++ b/.github/workflows/release-image.yml
@@ -26,7 +26,7 @@ jobs:
     runs-on: ${{ matrix.platform.runner }}
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
       - name: Install Podman # Not available in arm64 image
         run: |
           sudo apt-get update
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 2c5cc01..eebf4f9 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -26,8 +26,8 @@ jobs:
     runs-on: macos-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
-      - uses: actions/setup-go@v5
+        uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
         with:
           go-version: ${{ env.GO_VERSION }}
       - name: Build
@@ -57,8 +57,8 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
-      - uses: astral-sh/setup-uv@v5
+        uses: actions/checkout@v6
+      - uses: astral-sh/setup-uv@v7
       - name: Publish Python
         run:
           make python-publish
diff --git a/README.md b/README.md
index c6030bd..d58d14d 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 <a id="drop-in-configuration"></a>
+
+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 <pid>
+
+# 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 <a id="tools-and-functionalities"></a>
 
 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.
@@ -323,6 +413,13 @@ In case multi-cluster support is enabled (default) and you have access to multip
   - `name` (`string`) **(required)** - Name of the resource
   - `namespace` (`string`) - Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace
 
+- **resources_scale** - Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource
+  - `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are apps/v1)
+  - `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: StatefulSet, Deployment)
+  - `name` (`string`) **(required)** - Name of the resource
+  - `namespace` (`string`) - Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace
+  - `scale` (`integer`) - Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it
+
 </details>
 
 <details>
diff --git a/go.mod b/go.mod
index b84f28a..d639367 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/mark3labs/mcp-go v0.43.1
 	github.com/modelcontextprotocol/go-sdk v1.1.0
 	github.com/spf13/afero v1.15.0
-	github.com/spf13/cobra v1.10.1
+	github.com/spf13/cobra v1.10.2
 	github.com/spf13/pflag v1.0.10
 	github.com/stretchr/testify v1.11.1
 	golang.org/x/oauth2 v0.33.0
diff --git a/go.sum b/go.sum
index 2a04ced..b93698f 100644
--- a/go.sum
+++ b/go.sum
@@ -276,8 +276,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
 github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
 github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
-github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
diff --git a/pkg/api/params.go b/pkg/api/params.go
new file mode 100644
index 0000000..85176b1
--- /dev/null
+++ b/pkg/api/params.go
@@ -0,0 +1,28 @@
+package api
+
+import "fmt"
+
+// ErrInvalidInt64Type is returned when a value cannot be converted to int64.
+type ErrInvalidInt64Type struct {
+	Value interface{}
+}
+
+func (e *ErrInvalidInt64Type) Error() string {
+	return fmt.Sprintf("expected integer, got %T", e.Value)
+}
+
+// ParseInt64 converts an interface{} value (typically from JSON-decoded tool arguments)
+// to int64. Handles float64 (JSON numbers), int, and int64 types.
+// Returns the converted value and nil error on success, or 0 and an ErrInvalidInt64Type if the type is unsupported.
+func ParseInt64(value interface{}) (int64, error) {
+	switch v := value.(type) {
+	case float64:
+		return int64(v), nil
+	case int:
+		return int64(v), nil
+	case int64:
+		return v, nil
+	default:
+		return 0, &ErrInvalidInt64Type{Value: value}
+	}
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 85bf74d..836b248 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -6,8 +6,15 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"sort"
+	"strings"
 
 	"github.com/BurntSushi/toml"
+	"k8s.io/klog/v2"
+)
+
+const (
+	DefaultDropInConfigDir = "conf.d"
 )
 
 const (
@@ -96,29 +103,169 @@ 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, dropInConfigDir string) (*StaticConfig, error) {
+	var configFiles []string
+	var configDir string
+
+	// Main config file
+	if configPath != "" {
+		klog.V(2).Infof("Loading main config from: %s", configPath)
+		configFiles = append(configFiles, 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)
+		}
+		configDir = filepath.Dir(absPath)
+	}
+
+	// Drop-in config files
+	if dropInConfigDir == "" {
+		dropInConfigDir = DefaultDropInConfigDir
+	}
+
+	// Resolve drop-in config directory path (relative paths are resolved against config directory)
+	if configDir != "" && !filepath.IsAbs(dropInConfigDir) {
+		dropInConfigDir = filepath.Join(configDir, dropInConfigDir)
+	}
+
+	if configDir == "" {
+		configDir = dropInConfigDir
+	}
+
+	dropInFiles, err := loadDropInConfigs(dropInConfigDir)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", dropInConfigDir, err)
 	}
+	if len(dropInFiles) == 0 {
+		klog.V(2).Infof("No drop-in config files found in: %s", dropInConfigDir)
+	} else {
+		klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(dropInFiles), dropInConfigDir)
+	}
+	configFiles = append(configFiles, dropInFiles...)
 
-	// get and save the absolute dir path to the config file, so that other config parsers can use it
-	absPath, err := filepath.Abs(configPath)
+	// Read and merge all config files
+	configData, err := readAndMergeFiles(configFiles)
 	if err != nil {
-		return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
+		return nil, fmt.Errorf("failed to read and merge config files: %w", err)
 	}
-	dirPath := filepath.Dir(absPath)
 
-	cfg, err := ReadToml(configData, append(opts, WithDirPath(dirPath))...)
+	return ReadToml(configData, WithDirPath(configDir))
+}
+
+// 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(dropInConfigDir string) ([]string, error) {
+	// Check if directory exists
+	info, err := os.Stat(dropInConfigDir)
 	if err != nil {
-		return nil, err
+		if os.IsNotExist(err) {
+			klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInConfigDir)
+			return nil, nil
+		}
+		return nil, fmt.Errorf("failed to stat drop-in directory: %w", err)
+	}
+
+	if !info.IsDir() {
+		return nil, fmt.Errorf("drop-in config path is not a directory: %s", dropInConfigDir)
+	}
+
+	// Get all .toml files in the directory
+	return getSortedConfigFiles(dropInConfigDir)
+}
+
+// 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)
 	}
 
-	return cfg, nil
+	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
+}
+
+// readAndMergeFiles reads and merges multiple TOML config files into a single byte slice.
+// Files are merged in the order provided, with later files overriding earlier ones.
+func readAndMergeFiles(files []string) ([]byte, error) {
+	rawConfig := map[string]interface{}{}
+	// Merge each file in order using deep merge
+	for _, file := range files {
+		klog.V(3).Infof("  - Merging config: %s", filepath.Base(file))
+		configData, err := os.ReadFile(file)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read config %s: %w", file, err)
+		}
+
+		dropInConfig := make(map[string]interface{})
+		if _, err = toml.NewDecoder(bytes.NewReader(configData)).Decode(&dropInConfig); err != nil {
+			return nil, fmt.Errorf("failed to decode config %s: %w", file, err)
+		}
+
+		deepMerge(rawConfig, dropInConfig)
+	}
+
+	bufferedConfig := new(bytes.Buffer)
+	if err := toml.NewEncoder(bufferedConfig).Encode(rawConfig); err != nil {
+		return nil, fmt.Errorf("failed to encode merged config: %w", err)
+	}
+	return bufferedConfig.Bytes(), nil
+}
+
+// deepMerge recursively merges src into dst.
+// For nested maps, it merges recursively. For other types, src overwrites dst.
+func deepMerge(dst, src map[string]interface{}) {
+	for key, srcVal := range src {
+		if dstVal, exists := dst[key]; exists {
+			// Both have this key - check if both are maps for recursive merge
+			srcMap, srcIsMap := srcVal.(map[string]interface{})
+			dstMap, dstIsMap := dstVal.(map[string]interface{})
+			if srcIsMap && dstIsMap {
+				deepMerge(dstMap, srcMap)
+				continue
+			}
+		}
+		// Either key doesn't exist in dst, or values aren't both maps - overwrite
+		dst[key] = srcVal
+	}
 }
 
-// ReadToml reads the toml data and returns the StaticConfig, with any opts applied
+// ReadToml reads the toml data, loads and 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 ReadToml(configData []byte, opts ...ReadConfigOpt) (*StaticConfig, error) {
 	config := Default()
 	md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(config)
diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go
index b747cc0..42ed611 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). Defaults to "+config.DefaultDropInConfigDir+" relative to the config file if --config is set.")
 	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")
@@ -154,8 +159,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
 }
 
 func (m *MCPServerOptions) Complete(cmd *cobra.Command) error {
-	if m.ConfigPath != "" {
-		cnf, err := config.Read(m.ConfigPath)
+	if m.ConfigPath != "" || m.ConfigDir != "" {
+		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/resources.go b/pkg/kubernetes/resources.go
index 4f25052..55091f9 100644
--- a/pkg/kubernetes/resources.go
+++ b/pkg/kubernetes/resources.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/dynamic"
 
 	"github.com/containers/kubernetes-mcp-server/pkg/version"
 	authv1 "k8s.io/api/authorization/v1"
@@ -86,6 +87,50 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
 	return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
 }
 
+func (k *Kubernetes) ResourcesScale(
+	ctx context.Context,
+	gvk *schema.GroupVersionKind,
+	namespace, name string,
+	desiredScale int64,
+	shouldScale bool,
+) (*unstructured.Unstructured, error) {
+	gvr, err := k.resourceFor(gvk)
+	if err != nil {
+		return nil, err
+	}
+
+	var resourceClient dynamic.ResourceInterface
+
+	if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
+		resourceClient = k.
+			AccessControlClientset().
+			DynamicClient().
+			Resource(*gvr).
+			Namespace(k.NamespaceOrDefault(namespace))
+	} else {
+		resourceClient = k.
+			AccessControlClientset().DynamicClient().Resource(*gvr)
+	}
+
+	scale, err := resourceClient.Get(ctx, name, metav1.GetOptions{}, "scale")
+	if err != nil {
+		return nil, err
+	}
+
+	if shouldScale {
+		if err := unstructured.SetNestedField(scale.Object, desiredScale, "spec", "replicas"); err != nil {
+			return scale, fmt.Errorf("failed to set .spec.replicas on scale object %v: %w", scale, err)
+		}
+
+		scale, err = resourceClient.Update(ctx, scale, metav1.UpdateOptions{}, "scale")
+		if err != nil {
+			return scale, fmt.Errorf("failed to update scale: %w", err)
+		}
+	}
+
+	return scale, nil
+}
+
 // resourcesListAsTable retrieves a list of resources in a table format.
 // It's almost identical to the dynamic.DynamicClient implementation, but it uses a specific Accept header to request the table format.
 // dynamic.DynamicClient does not provide a way to set the HTTP header (TODO: create an issue to request this feature)
diff --git a/pkg/kubernetes/watcher/kubeconfig.go b/pkg/kubernetes/watcher/kubeconfig.go
index c7d06af..078fd80 100644
--- a/pkg/kubernetes/watcher/kubeconfig.go
+++ b/pkg/kubernetes/watcher/kubeconfig.go
@@ -1,24 +1,64 @@
 package watcher
 
 import (
+	"os"
+	"strconv"
+	"sync"
+	"time"
+
 	"github.com/fsnotify/fsnotify"
 	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/klog/v2"
+)
+
+const (
+	// DefaultKubeconfigDebounceWindow is the default debounce window for kubeconfig file changes
+	DefaultKubeconfigDebounceWindow = 100 * time.Millisecond
 )
 
 type Kubeconfig struct {
 	clientcmd.ClientConfig
-	close func()
+	debounceWindow time.Duration
+	debounceTimer  *time.Timer
+	mu             sync.Mutex
+	stopCh         chan struct{}
+	stoppedCh      chan struct{}
+	started        bool
 }
 
 var _ Watcher = (*Kubeconfig)(nil)
 
 func NewKubeconfig(clientConfig clientcmd.ClientConfig) *Kubeconfig {
+	debounceWindow := DefaultKubeconfigDebounceWindow
+
+	// Allow override via environment variable for testing
+	if envDebounce := os.Getenv("KUBECONFIG_DEBOUNCE_WINDOW_MS"); envDebounce != "" {
+		if ms, err := strconv.Atoi(envDebounce); err == nil && ms > 0 {
+			debounceWindow = time.Duration(ms) * time.Millisecond
+			klog.V(2).Infof("Using custom kubeconfig debounce window: %v", debounceWindow)
+		}
+	}
+
 	return &Kubeconfig{
-		ClientConfig: clientConfig,
+		ClientConfig:   clientConfig,
+		debounceWindow: debounceWindow,
+		stopCh:         make(chan struct{}),
+		stoppedCh:      make(chan struct{}),
 	}
 }
 
+// Watch starts a background watcher that monitors kubeconfig file changes
+// and triggers a debounced reload when changes are detected.
+// It can only be called once per Kubeconfig instance.
 func (w *Kubeconfig) Watch(onChange func() error) {
+	w.mu.Lock()
+	if w.started {
+		w.mu.Unlock()
+		return
+	}
+	w.started = true
+	w.mu.Unlock()
+
 	kubeConfigFiles := w.ConfigAccess().GetLoadingPrecedence()
 	if len(kubeConfigFiles) == 0 {
 		return
@@ -30,14 +70,34 @@ func (w *Kubeconfig) Watch(onChange func() error) {
 	for _, file := range kubeConfigFiles {
 		_ = watcher.Add(file)
 	}
+
 	go func() {
+		defer close(w.stoppedCh)
+		defer func() { _ = watcher.Close() }()
+
+		klog.V(2).Infof("Started kubeconfig watcher (debounce: %v)", w.debounceWindow)
+
 		for {
 			select {
+			case <-w.stopCh:
+				klog.V(2).Info("Stopping kubeconfig watcher")
+				return
 			case _, ok := <-watcher.Events:
 				if !ok {
 					return
 				}
-				_ = onChange()
+				w.mu.Lock()
+				klog.V(3).Info("Kubeconfig file change detected, scheduling debounced reload")
+				if w.debounceTimer != nil {
+					w.debounceTimer.Stop()
+				}
+				w.debounceTimer = time.AfterFunc(w.debounceWindow, func() {
+					klog.V(2).Info("Kubeconfig debounce window expired, triggering reload")
+					if err := onChange(); err != nil {
+						klog.Errorf("Failed to reload after kubeconfig change: %v", err)
+					}
+				})
+				w.mu.Unlock()
 			case _, ok := <-watcher.Errors:
 				if !ok {
 					return
@@ -45,14 +105,37 @@ func (w *Kubeconfig) Watch(onChange func() error) {
 			}
 		}
 	}()
-	if w.close != nil {
-		w.close()
-	}
-	w.close = func() { _ = watcher.Close() }
 }
 
+// Close stops the kubeconfig watcher
 func (w *Kubeconfig) Close() {
-	if w.close != nil {
-		w.close()
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	if w.debounceTimer != nil {
+		w.debounceTimer.Stop()
+	}
+
+	if w.stopCh == nil || w.stoppedCh == nil {
+		return // Already closed
+	}
+
+	if !w.started {
+		return
+	}
+
+	select {
+	case <-w.stopCh:
+		// Already closed or stopped
+		return
+	default:
+		close(w.stopCh)
+		w.mu.Unlock()
+		<-w.stoppedCh
+		w.mu.Lock()
+		w.started = false
+		// Recreate channels for potential restart
+		w.stopCh = make(chan struct{})
+		w.stoppedCh = make(chan struct{})
 	}
 }
diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go
index 7d663ef..9c86598 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/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go
index e42a8a9..1d070a8 100644
--- a/pkg/toolsets/core/nodes.go
+++ b/pkg/toolsets/core/nodes.go
@@ -108,15 +108,10 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
 	tailLines := params.GetArguments()["tailLines"]
 	var tailInt int64
 	if tailLines != nil {
-		// Convert to int64 - safely handle both float64 (JSON number) and int types
-		switch v := tailLines.(type) {
-		case float64:
-			tailInt = int64(v)
-		case int:
-		case int64:
-			tailInt = v
-		default:
-			return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tailLines)), nil
+		var err error
+		tailInt, err = api.ParseInt64(tailLines)
+		if err != nil {
+			return api.NewToolCallResult("", fmt.Errorf("failed to parse tailLines parameter: %w", err)), nil
 		}
 	}
 	ret, err := params.NodesLog(params, name, query, tailInt)
diff --git a/pkg/toolsets/core/pods.go b/pkg/toolsets/core/pods.go
index 7878133..f198868 100644
--- a/pkg/toolsets/core/pods.go
+++ b/pkg/toolsets/core/pods.go
@@ -397,16 +397,10 @@ func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
 	tail := params.GetArguments()["tail"]
 	var tailInt int64
 	if tail != nil {
-		// Convert to int64 - safely handle both float64 (JSON number) and int types
-		switch v := tail.(type) {
-		case float64:
-			tailInt = int64(v)
-		case int:
-			tailInt = int64(v)
-		case int64:
-			tailInt = v
-		default:
-			return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: expected integer, got %T", tail)), nil
+		var err error
+		tailInt, err = api.ParseInt64(tail)
+		if err != nil {
+			return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: %w", err)), nil
 		}
 	}
 
diff --git a/pkg/toolsets/core/resources.go b/pkg/toolsets/core/resources.go
index 52a613b..33b92bf 100644
--- a/pkg/toolsets/core/resources.go
+++ b/pkg/toolsets/core/resources.go
@@ -138,6 +138,42 @@ func initResources(o internalk8s.Openshift) []api.ServerTool {
 				OpenWorldHint:   ptr.To(true),
 			},
 		}, Handler: resourcesDelete},
+		{Tool: api.Tool{
+			Name:        "resources_scale",
+			Description: "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+			InputSchema: &jsonschema.Schema{
+				Type: "object",
+				Properties: map[string]*jsonschema.Schema{
+					"apiVersion": {
+						Type:        "string",
+						Description: "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+					},
+					"kind": {
+						Type:        "string",
+						Description: "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+					},
+					"namespace": {
+						Type:        "string",
+						Description: "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+					},
+					"name": {
+						Type:        "string",
+						Description: "Name of the resource",
+					},
+					"scale": {
+						Type:        "integer",
+						Description: "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+					},
+				},
+				Required: []string{"apiVersion", "kind", "name"},
+			},
+			Annotations: api.ToolAnnotations{
+				Title:           "Resources: Scale",
+				DestructiveHint: ptr.To(true),
+				IdempotentHint:  ptr.To(true),
+				OpenWorldHint:   ptr.To(true),
+			},
+		}, Handler: resourcesScale},
 	}
 }
 
@@ -259,6 +295,64 @@ func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error)
 	return api.NewToolCallResult("Resource deleted successfully", err), nil
 }
 
+func resourcesScale(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
+	namespace := params.GetArguments()["namespace"]
+	if namespace == nil {
+		namespace = ""
+	}
+
+	gvk, err := parseGroupVersionKind(params.GetArguments())
+	if err != nil {
+		return api.NewToolCallResult("", fmt.Errorf("failed to get/update resource scale, %w", err)), nil
+	}
+
+	name := params.GetArguments()["name"]
+	if name == nil {
+		return api.NewToolCallResult("", errors.New("failed to get/update resource scale, missing argument name")), nil
+	}
+
+	ns, ok := namespace.(string)
+	if !ok {
+		return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
+	}
+
+	ns = params.NamespaceOrDefault(ns)
+
+	n, ok := name.(string)
+	if !ok {
+		return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
+	}
+
+	var desiredScale int64
+	scaleVal, shouldScale := params.GetArguments()["scale"]
+	if shouldScale {
+		desiredScale, err = parseScaleValue(scaleVal)
+		if err != nil {
+			return api.NewToolCallResult("", err), nil
+		}
+	}
+
+	scale, err := params.ResourcesScale(params.Context, gvk, ns, n, desiredScale, shouldScale)
+	if err != nil {
+		return api.NewToolCallResult("", fmt.Errorf("failed to get/update resource scale: %w", err)), nil
+	}
+
+	marshalled, err := output.MarshalYaml(scale)
+	if err != nil {
+		return api.NewToolCallResult("", fmt.Errorf("failed to marshall scale to yaml format: %v", scale)), nil
+	}
+
+	return api.NewToolCallResult("# Current resource scale (YAML) is below\n"+marshalled, err), nil
+}
+
+func parseScaleValue(desiredScale interface{}) (int64, error) {
+	v, err := api.ParseInt64(desiredScale)
+	if err != nil {
+		return 0, fmt.Errorf("failed to parse scale parameter: %w", err)
+	}
+	return v, nil
+}
+
 func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
 	apiVersion := arguments["apiVersion"]
 	if apiVersion == nil {
diff --git a/pkg/toolsets/kiali/logs.go b/pkg/toolsets/kiali/logs.go
index 5c74ce6..8c9521c 100644
--- a/pkg/toolsets/kiali/logs.go
+++ b/pkg/toolsets/kiali/logs.go
@@ -86,14 +86,11 @@ func workloadLogsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, err
 
 	// Convert tail to maxLines
 	if tail != nil {
-		switch v := tail.(type) {
-		case float64:
-			maxLines = fmt.Sprintf("%.0f", v)
-		case int:
-			maxLines = fmt.Sprintf("%d", v)
-		case int64:
-			maxLines = fmt.Sprintf("%d", v)
+		tailInt, err := api.ParseInt64(tail)
+		if err != nil {
+			return api.NewToolCallResult("", fmt.Errorf("failed to parse tail parameter: %w", err)), nil
 		}
+		maxLines = fmt.Sprintf("%d", tailInt)
 	}
 
 	// If no container specified, we need to get workload details first to find the main app container



Test patch:
diff --git a/pkg/api/params_test.go b/pkg/api/params_test.go
new file mode 100644
index 0000000..c2d8060
--- /dev/null
+++ b/pkg/api/params_test.go
@@ -0,0 +1,105 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type ParamsSuite struct {
+	suite.Suite
+}
+
+func TestParamsSuite(t *testing.T) {
+	suite.Run(t, new(ParamsSuite))
+}
+
+func (s *ParamsSuite) TestParseInt64() {
+	s.Run("float64 value is converted to int64", func() {
+		result, err := ParseInt64(float64(42.0))
+		s.NoError(err)
+		s.Equal(int64(42), result)
+	})
+
+	s.Run("float64 with decimal truncates to int64", func() {
+		result, err := ParseInt64(float64(42.9))
+		s.NoError(err)
+		s.Equal(int64(42), result)
+	})
+
+	s.Run("int value is converted to int64", func() {
+		result, err := ParseInt64(int(100))
+		s.NoError(err)
+		s.Equal(int64(100), result)
+	})
+
+	s.Run("int64 value is returned as-is", func() {
+		result, err := ParseInt64(int64(999))
+		s.NoError(err)
+		s.Equal(int64(999), result)
+	})
+
+	s.Run("negative float64 value is converted correctly", func() {
+		result, err := ParseInt64(float64(-10.0))
+		s.NoError(err)
+		s.Equal(int64(-10), result)
+	})
+
+	s.Run("negative int value is converted correctly", func() {
+		result, err := ParseInt64(int(-5))
+		s.NoError(err)
+		s.Equal(int64(-5), result)
+	})
+
+	s.Run("zero value is handled correctly", func() {
+		result, err := ParseInt64(float64(0))
+		s.NoError(err)
+		s.Equal(int64(0), result)
+	})
+
+	s.Run("string value returns error", func() {
+		result, err := ParseInt64("not a number")
+		s.Error(err)
+		s.Equal(int64(0), result)
+		s.Contains(err.Error(), "string")
+	})
+
+	s.Run("nil value returns error", func() {
+		result, err := ParseInt64(nil)
+		s.Error(err)
+		s.Equal(int64(0), result)
+	})
+
+	s.Run("bool value returns error", func() {
+		result, err := ParseInt64(true)
+		s.Error(err)
+		s.Equal(int64(0), result)
+		s.Contains(err.Error(), "bool")
+	})
+
+	s.Run("slice value returns error", func() {
+		result, err := ParseInt64([]int{1, 2, 3})
+		s.Error(err)
+		s.Equal(int64(0), result)
+	})
+
+	s.Run("map value returns error", func() {
+		result, err := ParseInt64(map[string]int{"a": 1})
+		s.Error(err)
+		s.Equal(int64(0), result)
+	})
+}
+
+func (s *ParamsSuite) TestErrInvalidInt64Type() {
+	s.Run("error includes type information", func() {
+		err := &ErrInvalidInt64Type{Value: "test"}
+		s.Contains(err.Error(), "string")
+	})
+
+	s.Run("error can be type asserted", func() {
+		_, err := ParseInt64("invalid")
+		var typeErr *ErrInvalidInt64Type
+		s.ErrorAs(err, &typeErr)
+		s.Equal("invalid", typeErr.Value)
+	})
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index b3fb2b4..2d5feba 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,785 @@ 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")
+		}
+	})
+}
+
+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)
+
+	// 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("later drop-in overrides earlier drop-in", func() {
+		s.Equal("7777", config.Port, "port from 20-final.toml should override 10-override.toml")
+	})
+
+	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.Run("applies all drop-in changes", func() {
+		s.Equal("yaml", config.ListOutput, "list_output from 20-final.toml should be applied")
+	})
+}
+
+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)
+
+	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.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")
+	})
+}
+
+func (s *ConfigSuite) TestDefaultConfDResolution() {
+	// Create a temp directory structure:
+	// tempDir/
+	//   config.toml
+	//   conf.d/
+	//     10-override.toml
+	tempDir := s.T().TempDir()
+
+	// Create main config file
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+		port = "8080"
+	`), 0644))
+
+	// Create default conf.d directory
+	confDDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(confDDir, 0755))
+
+	// Create drop-in file in conf.d
+	dropIn := filepath.Join(confDDir, "10-override.toml")
+	s.Require().NoError(os.WriteFile(dropIn, []byte(`
+		log_level = 5
+		port = "9090"
+	`), 0644))
+
+	// Read config WITHOUT specifying drop-in directory - should auto-discover conf.d
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("auto-discovers conf.d relative to config file", func() {
+		s.Equal(5, config.LogLevel, "log_level should be overridden by conf.d/10-override.toml")
+		s.Equal("9090", config.Port, "port should be overridden by conf.d/10-override.toml")
+	})
+}
+
+func (s *ConfigSuite) TestDefaultConfDNotExist() {
+	// When conf.d doesn't exist, config should still load without error
+	mainConfigPath := s.writeConfig(`
+		log_level = 3
+		port = "8080"
+	`)
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err, "Should not error when default conf.d doesn't exist")
+	s.Require().NotNil(config)
+
+	s.Run("loads main config when conf.d doesn't exist", func() {
+		s.Equal(3, config.LogLevel)
+		s.Equal("8080", config.Port)
+	})
+}
+
+func (s *ConfigSuite) TestStandaloneConfigDir() {
+	// Test using only --config-dir without --config (standalone mode)
+	tempDir := s.T().TempDir()
+
+	// Create first drop-in file
+	dropIn1 := filepath.Join(tempDir, "10-base.toml")
+	s.Require().NoError(os.WriteFile(dropIn1, []byte(`
+		log_level = 2
+		port = "8080"
+		toolsets = ["core"]
+	`), 0644))
+
+	// Create second drop-in file
+	dropIn2 := filepath.Join(tempDir, "20-override.toml")
+	s.Require().NoError(os.WriteFile(dropIn2, []byte(`
+		log_level = 5
+		list_output = "yaml"
+	`), 0644))
+
+	// Read with empty config path (standalone --config-dir)
+	config, err := Read("", tempDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("loads config from drop-in directory only", func() {
+		s.Equal(5, config.LogLevel, "log_level should be from 20-override.toml")
+		s.Equal("8080", config.Port, "port should be from 10-base.toml")
+		s.Equal("yaml", config.ListOutput, "list_output should be from 20-override.toml")
+		s.Equal([]string{"core"}, config.Toolsets, "toolsets should be from 10-base.toml")
+	})
+}
+
+func (s *ConfigSuite) TestStandaloneConfigDirPreservesDefaults() {
+	// Test that defaults are preserved when using standalone --config-dir
+	if HasDefaultOverrides() {
+		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
+	}
+	tempDir := s.T().TempDir()
+
+	// Create a drop-in file with only partial config
+	dropIn := filepath.Join(tempDir, "10-partial.toml")
+	s.Require().NoError(os.WriteFile(dropIn, []byte(`
+		port = "9999"
+	`), 0644))
+
+	config, err := Read("", tempDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("preserves default values", func() {
+		s.Equal("9999", config.Port, "port should be from drop-in")
+		s.Equal("table", config.ListOutput, "list_output should be default")
+		s.Equal([]string{"core", "config", "helm"}, config.Toolsets, "toolsets should be default")
+	})
+}
+
+func (s *ConfigSuite) TestStandaloneConfigDirEmpty() {
+	// Test standalone --config-dir with empty directory
+	if HasDefaultOverrides() {
+		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
+	}
+	tempDir := s.T().TempDir()
+
+	config, err := Read("", tempDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("returns defaults for empty directory", func() {
+		s.Equal("table", config.ListOutput, "list_output should be default")
+		s.Equal([]string{"core", "config", "helm"}, config.Toolsets, "toolsets should be default")
+	})
+}
+
+func (s *ConfigSuite) TestStandaloneConfigDirNonExistent() {
+	// Test standalone --config-dir with non-existent directory
+	if HasDefaultOverrides() {
+		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
+	}
+	config, err := Read("", "/non/existent/directory")
+	s.Require().NoError(err, "Should not error for non-existent directory")
+	s.Require().NotNil(config)
+
+	s.Run("returns defaults for non-existent directory", func() {
+		s.Equal("table", config.ListOutput, "list_output should be default")
+	})
+}
+
+func (s *ConfigSuite) TestConfigDirOverridesDefaultConfD() {
+	// Test that explicit --config-dir overrides default conf.d
+	tempDir := s.T().TempDir()
+
+	// Create main config file
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+		port = "8080"
+	`), 0644))
+
+	// Create default conf.d directory with a drop-in
+	confDDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(confDDir, 0755))
+	s.Require().NoError(os.WriteFile(filepath.Join(confDDir, "10-default.toml"), []byte(`
+		log_level = 99
+		port = "1111"
+	`), 0644))
+
+	// Create custom drop-in directory
+	customDir := filepath.Join(tempDir, "custom.d")
+	s.Require().NoError(os.Mkdir(customDir, 0755))
+	s.Require().NoError(os.WriteFile(filepath.Join(customDir, "10-custom.toml"), []byte(`
+		log_level = 5
+		port = "9090"
+	`), 0644))
+
+	// Read with explicit config-dir (should override default conf.d)
+	config, err := Read(mainConfigPath, customDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("uses explicit config-dir instead of default conf.d", func() {
+		s.Equal(5, config.LogLevel, "log_level should be from custom.d, not conf.d")
+		s.Equal("9090", config.Port, "port should be from custom.d, not conf.d")
+	})
+}
+
+func (s *ConfigSuite) TestInvalidTomlInDropIn() {
+	tempDir := s.T().TempDir()
+
+	mainConfigPath := s.writeConfig(`
+		log_level = 1
+	`)
+
+	// Create drop-in directory with invalid TOML
+	dropInDir := filepath.Join(tempDir, "config.d")
+	s.Require().NoError(os.Mkdir(dropInDir, 0755))
+
+	// Valid first file
+	s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-valid.toml"), []byte(`
+		port = "8080"
+	`), 0644))
+
+	// Invalid second file
+	s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "20-invalid.toml"), []byte(`
+		port = "unclosed string
+	`), 0644))
+
+	config, err := Read(mainConfigPath, dropInDir)
+
+	s.Run("returns error for invalid TOML in drop-in", func() {
+		s.Require().Error(err, "Expected error for invalid TOML")
+		s.Contains(err.Error(), "20-invalid.toml", "Error should mention the invalid file")
+	})
+
+	s.Run("returns nil config", func() {
+		s.Nil(config, "Expected nil config when drop-in has invalid TOML")
+	})
+}
+
+func (s *ConfigSuite) TestAbsoluteDropInConfigDir() {
+	// Test that absolute paths work for --config-dir
+	tempDir := s.T().TempDir()
+
+	// Create main config in one directory
+	configDir := filepath.Join(tempDir, "config")
+	s.Require().NoError(os.Mkdir(configDir, 0755))
+	mainConfigPath := filepath.Join(configDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+	`), 0644))
+
+	// Create drop-in directory in a completely different location
+	dropInDir := filepath.Join(tempDir, "somewhere", "else", "conf.d")
+	s.Require().NoError(os.MkdirAll(dropInDir, 0755))
+	s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-override.toml"), []byte(`
+		log_level = 9
+		port = "7777"
+	`), 0644))
+
+	// Use absolute path for config-dir
+	absDropInDir, err := filepath.Abs(dropInDir)
+	s.Require().NoError(err)
+
+	config, err := Read(mainConfigPath, absDropInDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("loads from absolute drop-in path", func() {
+		s.Equal(9, config.LogLevel, "log_level should be from absolute path drop-in")
+		s.Equal("7777", config.Port, "port should be from absolute path drop-in")
+	})
+}
+
+func (s *ConfigSuite) TestDropInNotADirectory() {
+	tempDir := s.T().TempDir()
+
+	mainConfigPath := s.writeConfig(`
+		log_level = 1
+	`)
+
+	// Create a file (not a directory) where drop-in dir is expected
+	notADir := filepath.Join(tempDir, "not-a-dir")
+	s.Require().NoError(os.WriteFile(notADir, []byte("i am a file"), 0644))
+
+	config, err := Read(mainConfigPath, notADir)
+
+	s.Run("returns error when drop-in path is not a directory", func() {
+		s.Require().Error(err)
+		s.Contains(err.Error(), "not a directory")
+	})
+
+	s.Run("returns nil config", func() {
+		s.Nil(config)
+	})
+}
+
+func (s *ConfigSuite) TestDeepMerge() {
+	s.Run("merges flat maps", func() {
+		dst := map[string]interface{}{
+			"key1": "value1",
+			"key2": "value2",
+		}
+		src := map[string]interface{}{
+			"key2": "overridden",
+			"key3": "value3",
 		}
 
-		result := mergeConfig(base, override)
+		deepMerge(dst, src)
 
-		s.Equal("json", result.ListOutput, "ListOutput should be overridden")
-		s.Equal("9090", result.Port, "Port should be overridden")
+		s.Equal("value1", dst["key1"], "existing key should be preserved")
+		s.Equal("overridden", dst["key2"], "overlapping key should be overridden")
+		s.Equal("value3", dst["key3"], "new key should be added")
 	})
 
-	s.Run("preserves base values when override is empty", func() {
-		override := StaticConfig{}
+	s.Run("recursively merges nested maps", func() {
+		dst := map[string]interface{}{
+			"nested": map[string]interface{}{
+				"a": "original-a",
+				"b": "original-b",
+			},
+		}
+		src := map[string]interface{}{
+			"nested": map[string]interface{}{
+				"b": "overridden-b",
+				"c": "new-c",
+			},
+		}
+
+		deepMerge(dst, src)
+
+		nested := dst["nested"].(map[string]interface{})
+		s.Equal("original-a", nested["a"], "nested key not in src should be preserved")
+		s.Equal("overridden-b", nested["b"], "nested key in both should be overridden")
+		s.Equal("new-c", nested["c"], "new nested key should be added")
+	})
+
+	s.Run("overwrites when types differ", func() {
+		dst := map[string]interface{}{
+			"key": map[string]interface{}{"nested": "value"},
+		}
+		src := map[string]interface{}{
+			"key": "now-a-string",
+		}
 
-		result := mergeConfig(base, override)
+		deepMerge(dst, src)
 
-		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.Equal("now-a-string", dst["key"], "map should be replaced by string")
 	})
 
-	s.Run("handles partial overrides", func() {
-		override := StaticConfig{
-			Toolsets: []string{"custom"},
-			ReadOnly: true,
+	s.Run("replaces arrays completely", func() {
+		dst := map[string]interface{}{
+			"array": []interface{}{"a", "b", "c"},
 		}
+		src := map[string]interface{}{
+			"array": []interface{}{"x", "y"},
+		}
+
+		deepMerge(dst, src)
+
+		s.Equal([]interface{}{"x", "y"}, dst["array"], "arrays should be replaced, not merged")
+	})
+
+	s.Run("deeply nested merge", func() {
+		dst := map[string]interface{}{
+			"level1": map[string]interface{}{
+				"level2": map[string]interface{}{
+					"level3": map[string]interface{}{
+						"deep": "original",
+						"keep": "preserved",
+					},
+				},
+			},
+		}
+		src := map[string]interface{}{
+			"level1": map[string]interface{}{
+				"level2": map[string]interface{}{
+					"level3": map[string]interface{}{
+						"deep": "overridden",
+					},
+				},
+			},
+		}
+
+		deepMerge(dst, src)
+
+		level3 := dst["level1"].(map[string]interface{})["level2"].(map[string]interface{})["level3"].(map[string]interface{})
+		s.Equal("overridden", level3["deep"], "deeply nested key should be overridden")
+		s.Equal("preserved", level3["keep"], "deeply nested key not in src should be preserved")
+	})
+}
+
+func (s *ConfigSuite) TestDropInWithDeniedResources() {
+	tempDir := s.T().TempDir()
+
+	// Main config with some denied resources
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+		denied_resources = [
+			{group = "apps", version = "v1", kind = "Deployment"}
+		]
+	`), 0644))
 
-		result := mergeConfig(base, override)
+	// Create drop-in directory
+	dropInDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(dropInDir, 0755))
+
+	// Drop-in that replaces denied_resources (arrays are replaced, not merged)
+	s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-security.toml"), []byte(`
+		denied_resources = [
+			{group = "rbac.authorization.k8s.io", version = "v1", kind = "ClusterRole"},
+			{group = "rbac.authorization.k8s.io", version = "v1", kind = "ClusterRoleBinding"}
+		]
+	`), 0644))
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("drop-in replaces denied_resources array", func() {
+		s.Len(config.DeniedResources, 2, "denied_resources should have 2 entries from drop-in")
+		s.Contains(config.DeniedResources, GroupVersionKind{
+			Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole",
+		})
+		s.Contains(config.DeniedResources, GroupVersionKind{
+			Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRoleBinding",
+		})
+	})
+
+	s.Run("original denied_resources from main config are replaced", func() {
+		s.NotContains(config.DeniedResources, GroupVersionKind{
+			Group: "apps", Version: "v1", Kind: "Deployment",
+		}, "original entry should be replaced by drop-in")
+	})
+}
+
+func (s *ConfigSuite) TestRelativeConfigDirPath() {
+	// Test that relative --config-dir paths are resolved relative to --config file
+	tempDir := s.T().TempDir()
+
+	// Create main config in a subdirectory
+	configSubDir := filepath.Join(tempDir, "etc", "kmcp")
+	s.Require().NoError(os.MkdirAll(configSubDir, 0755))
+	mainConfigPath := filepath.Join(configSubDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+	`), 0644))
+
+	// Create a custom drop-in dir relative to config (sibling directory)
+	customDropInDir := filepath.Join(configSubDir, "overrides.d")
+	s.Require().NoError(os.Mkdir(customDropInDir, 0755))
+	s.Require().NoError(os.WriteFile(filepath.Join(customDropInDir, "10-override.toml"), []byte(`
+		log_level = 7
+		port = "3333"
+	`), 0644))
+
+	// Use relative path for config-dir
+	config, err := Read(mainConfigPath, "overrides.d")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("resolves relative config-dir against config file directory", func() {
+		s.Equal(7, config.LogLevel, "log_level should be from overrides.d")
+		s.Equal("3333", config.Port, "port should be from overrides.d")
+	})
+}
+
+func (s *ConfigSuite) TestBothConfigAndConfigDirEmpty() {
+	// Edge case: Read("", "") should return defaults
+	if HasDefaultOverrides() {
+		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
+	}
+
+	config, err := Read("", "")
+	s.Require().NoError(err, "Should not error when both config and config-dir are empty")
+	s.Require().NotNil(config)
+
+	s.Run("returns default configuration", func() {
+		s.Equal("table", config.ListOutput)
+		s.Equal([]string{"core", "config", "helm"}, config.Toolsets)
+		s.Equal(0, config.LogLevel)
+	})
+}
+
+func (s *ConfigSuite) TestMultipleDropInFilesInOrder() {
+	// Comprehensive test of file ordering with many files
+	tempDir := s.T().TempDir()
+
+	// Create main config
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 0
+		port = "initial"
+		list_output = "table"
+	`), 0644))
+
+	// Create conf.d with multiple files
+	confDDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(confDDir, 0755))
+
+	// Create files in non-alphabetical order to ensure sorting works
+	files := map[string]string{
+		"50-middle.toml": `port = "fifty"`,
+		"10-first.toml":  `log_level = 10`,
+		"90-last.toml":   `log_level = 90`,
+		"30-third.toml":  `list_output = "yaml"`,
+		"70-seven.toml":  `port = "seventy"`,
+	}
+
+	for name, content := range files {
+		s.Require().NoError(os.WriteFile(filepath.Join(confDDir, name), []byte(content), 0644))
+	}
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("processes files in lexical order", func() {
+		// log_level: main(0) -> 10-first(10) -> 90-last(90) = 90
+		s.Equal(90, config.LogLevel, "log_level should be from 90-last.toml (last to set it)")
+	})
+
+	s.Run("last file wins for each field", func() {
+		// port: main("initial") -> 50-middle("fifty") -> 70-seven("seventy") = "seventy"
+		s.Equal("seventy", config.Port, "port should be from 70-seven.toml (last to set it)")
+		// list_output: main("table") -> 30-third("yaml") = "yaml"
+		s.Equal("yaml", config.ListOutput, "list_output should be from 30-third.toml")
+	})
+}
+
+func (s *ConfigSuite) TestDropInWithNestedConfig() {
+	// Test that nested config structures (like cluster_provider_configs) merge correctly
+	tempDir := s.T().TempDir()
+
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(`
+		log_level = 1
+		cluster_provider_strategy = "kubeconfig"
+
+		[cluster_provider_configs.kubeconfig]
+		setting1 = "from-main"
+		setting2 = "from-main"
+	`), 0644))
+
+	confDDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(confDDir, 0755))
+
+	// First drop-in overrides one nested setting
+	s.Require().NoError(os.WriteFile(filepath.Join(confDDir, "10-partial.toml"), []byte(`
+		[cluster_provider_configs.kubeconfig]
+		setting1 = "from-drop-in-1"
+	`), 0644))
+
+	// Second drop-in overrides another nested setting
+	s.Require().NoError(os.WriteFile(filepath.Join(confDDir, "20-partial.toml"), []byte(`
+		[cluster_provider_configs.kubeconfig]
+		setting2 = "from-drop-in-2"
+		setting3 = "new-in-drop-in-2"
+	`), 0644))
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	s.Run("merges nested config from multiple drop-ins", func() {
+		// The raw ClusterProviderConfigs should have all merged keys
+		s.NotNil(config.ClusterProviderConfigs["kubeconfig"])
+	})
+}
+
+func (s *ConfigSuite) TestEmptyConfigFile() {
+	// Test that an empty main config file works correctly
+	if HasDefaultOverrides() {
+		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
+	}
+
+	tempDir := s.T().TempDir()
+
+	// Create empty main config
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(mainConfigPath, []byte(``), 0644))
+
+	// Create conf.d with overrides
+	confDDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(confDDir, 0755))
+	s.Require().NoError(os.WriteFile(filepath.Join(confDDir, "10-settings.toml"), []byte(`
+		log_level = 5
+		port = "9999"
+	`), 0644))
+
+	config, err := Read(mainConfigPath, "")
+	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("applies drop-in on top of defaults when main config is empty", func() {
+		s.Equal(5, config.LogLevel, "log_level should be from drop-in")
+		s.Equal("9999", config.Port, "port should be from drop-in")
+		// Defaults should still be applied for unset values
+		s.Equal("table", config.ListOutput, "list_output should be default")
+		s.Equal([]string{"core", "config", "helm"}, config.Toolsets, "toolsets should be default")
 	})
 }
 
diff --git a/pkg/config/provider_config_test.go b/pkg/config/provider_config_test.go
index 2afbd2d..f491131 100644
--- a/pkg/config/provider_config_test.go
+++ b/pkg/config/provider_config_test.go
@@ -3,6 +3,7 @@ package config
 import (
 	"context"
 	"errors"
+	"os"
 	"path/filepath"
 	"testing"
 
@@ -66,7 +67,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 +96,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 +115,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 +140,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 +171,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")
@@ -178,6 +179,186 @@ func (s *ProviderConfigSuite) TestConfigDirPathInContext() {
 	})
 }
 
+func (s *ProviderConfigSuite) TestExtendedConfigMergingAcrossDropIns() {
+	// Test that extended configs (cluster_provider_configs) are properly merged
+	// when scattered across multiple drop-in files
+	RegisterProviderConfig("test", providerConfigForTestParser)
+
+	tempDir := s.T().TempDir()
+
+	// Create main config with initial provider config
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	err := os.WriteFile(mainConfigPath, []byte(`
+		cluster_provider_strategy = "test"
+		[cluster_provider_configs.test]
+		bool_prop = false
+		str_prop = "from-main"
+		int_prop = 1
+	`), 0644)
+	s.Require().NoError(err)
+
+	// Create drop-in directory
+	dropInDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(dropInDir, 0755))
+
+	// First drop-in overrides some fields
+	err = os.WriteFile(filepath.Join(dropInDir, "10-override.toml"), []byte(`
+		[cluster_provider_configs.test]
+		bool_prop = true
+		int_prop = 10
+	`), 0644)
+	s.Require().NoError(err)
+
+	// Second drop-in overrides other fields
+	err = os.WriteFile(filepath.Join(dropInDir, "20-final.toml"), []byte(`
+		[cluster_provider_configs.test]
+		str_prop = "from-drop-in"
+		int_prop = 42
+	`), 0644)
+	s.Require().NoError(err)
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	providerConfig, ok := config.GetProviderConfig("test")
+	s.Require().True(ok, "Expected to find provider config")
+
+	testConfig, ok := providerConfig.(*ProviderConfigForTest)
+	s.Require().True(ok, "Expected provider config to be *ProviderConfigForTest")
+
+	s.Run("merges bool_prop from first drop-in", func() {
+		s.True(testConfig.BoolProp, "bool_prop should be true from 10-override.toml")
+	})
+
+	s.Run("merges str_prop from second drop-in", func() {
+		s.Equal("from-drop-in", testConfig.StrProp, "str_prop should be from 20-final.toml")
+	})
+
+	s.Run("last drop-in wins for int_prop", func() {
+		s.Equal(42, testConfig.IntProp, "int_prop should be 42 from 20-final.toml")
+	})
+}
+
+func (s *ProviderConfigSuite) TestExtendedConfigFromDropInOnly() {
+	// Test that extended configs work when defined only in drop-in files (not in main config)
+	RegisterProviderConfig("test", providerConfigForTestParser)
+
+	tempDir := s.T().TempDir()
+
+	// Create main config WITHOUT provider config
+	mainConfigPath := filepath.Join(tempDir, "config.toml")
+	err := os.WriteFile(mainConfigPath, []byte(`
+		cluster_provider_strategy = "test"
+		log_level = 1
+	`), 0644)
+	s.Require().NoError(err)
+
+	// Create drop-in directory
+	dropInDir := filepath.Join(tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(dropInDir, 0755))
+
+	// Drop-in defines the provider config
+	err = os.WriteFile(filepath.Join(dropInDir, "10-provider.toml"), []byte(`
+		[cluster_provider_configs.test]
+		bool_prop = true
+		str_prop = "from-drop-in-only"
+		int_prop = 99
+	`), 0644)
+	s.Require().NoError(err)
+
+	config, err := Read(mainConfigPath, "")
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	providerConfig, ok := config.GetProviderConfig("test")
+	s.Require().True(ok, "Expected to find provider config from drop-in")
+
+	testConfig, ok := providerConfig.(*ProviderConfigForTest)
+	s.Require().True(ok)
+
+	s.Run("loads extended config from drop-in only", func() {
+		s.True(testConfig.BoolProp)
+		s.Equal("from-drop-in-only", testConfig.StrProp)
+		s.Equal(99, testConfig.IntProp)
+	})
+}
+
+func (s *ProviderConfigSuite) TestStandaloneConfigDirWithExtendedConfig() {
+	// Test that extended configs work with standalone --config-dir (no main config)
+	RegisterProviderConfig("test", providerConfigForTestParser)
+
+	tempDir := s.T().TempDir()
+
+	// Create drop-in files only (no main config)
+	err := os.WriteFile(filepath.Join(tempDir, "10-base.toml"), []byte(`
+		cluster_provider_strategy = "test"
+		[cluster_provider_configs.test]
+		bool_prop = false
+		str_prop = "base"
+		int_prop = 1
+	`), 0644)
+	s.Require().NoError(err)
+
+	err = os.WriteFile(filepath.Join(tempDir, "20-override.toml"), []byte(`
+		[cluster_provider_configs.test]
+		bool_prop = true
+		int_prop = 100
+	`), 0644)
+	s.Require().NoError(err)
+
+	// Read with standalone config-dir (empty config path)
+	config, err := Read("", tempDir)
+	s.Require().NoError(err)
+	s.Require().NotNil(config)
+
+	providerConfig, ok := config.GetProviderConfig("test")
+	s.Require().True(ok, "Expected to find provider config in standalone mode")
+
+	testConfig, ok := providerConfig.(*ProviderConfigForTest)
+	s.Require().True(ok)
+
+	s.Run("merges extended config in standalone mode", func() {
+		s.True(testConfig.BoolProp, "bool_prop should be true from 20-override.toml")
+		s.Equal("base", testConfig.StrProp, "str_prop should be 'base' from 10-base.toml")
+		s.Equal(100, testConfig.IntProp, "int_prop should be 100 from 20-override.toml")
+	})
+}
+
+func (s *ProviderConfigSuite) TestConfigDirPathInContextStandalone() {
+	// Test that configDirPath is correctly set in context for standalone --config-dir
+	var capturedDirPath string
+	RegisterProviderConfig("test", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) {
+		capturedDirPath = ConfigDirPathFromContext(ctx)
+		var providerConfigForTest ProviderConfigForTest
+		if err := md.PrimitiveDecode(primitive, &providerConfigForTest); err != nil {
+			return nil, err
+		}
+		return &providerConfigForTest, nil
+	})
+
+	tempDir := s.T().TempDir()
+
+	err := os.WriteFile(filepath.Join(tempDir, "10-config.toml"), []byte(`
+		cluster_provider_strategy = "test"
+		[cluster_provider_configs.test]
+		bool_prop = true
+		str_prop = "test"
+		int_prop = 1
+	`), 0644)
+	s.Require().NoError(err)
+
+	absTempDir, err := filepath.Abs(tempDir)
+	s.Require().NoError(err)
+
+	_, err = Read("", tempDir)
+	s.Run("provides config directory path in context for standalone mode", func() {
+		s.Require().NoError(err)
+		s.NotEmpty(capturedDirPath, "Expected non-empty directory path in context")
+		s.Equal(absTempDir, capturedDirPath, "Expected directory path to match config-dir")
+	})
+}
+
 func TestProviderConfig(t *testing.T) {
 	suite.Run(t, new(ProviderConfigSuite))
 }
diff --git a/pkg/config/toolset_config_test.go b/pkg/config/toolset_config_test.go
index 86f79c6..5b0c236 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_sighup_test.go b/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go
new file mode 100644
index 0000000..e0d53e2
--- /dev/null
+++ b/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go
@@ -0,0 +1,209 @@
+//go:build !windows
+
+package cmd
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/containers/kubernetes-mcp-server/internal/test"
+	"github.com/containers/kubernetes-mcp-server/pkg/config"
+	"github.com/containers/kubernetes-mcp-server/pkg/mcp"
+	"github.com/stretchr/testify/suite"
+	"k8s.io/klog/v2"
+	"k8s.io/klog/v2/textlogger"
+)
+
+// SIGHUPSuite tests the SIGHUP configuration reload behavior
+type SIGHUPSuite struct {
+	suite.Suite
+	mockServer      *test.MockServer
+	server          *mcp.Server
+	tempDir         string
+	dropInConfigDir string
+	logBuffer       *bytes.Buffer
+}
+
+func (s *SIGHUPSuite) SetupTest() {
+	s.mockServer = test.NewMockServer()
+	s.mockServer.Handle(&test.DiscoveryClientHandler{})
+	s.tempDir = s.T().TempDir()
+	s.dropInConfigDir = filepath.Join(s.tempDir, "conf.d")
+	s.Require().NoError(os.Mkdir(s.dropInConfigDir, 0755))
+
+	// Set up klog to write to our buffer so we can verify log messages
+	s.logBuffer = &bytes.Buffer{}
+	logger := textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(2), textlogger.Output(s.logBuffer)))
+	klog.SetLoggerWithOptions(logger)
+}
+
+func (s *SIGHUPSuite) TearDownTest() {
+	if s.server != nil {
+		s.server.Close()
+	}
+	if s.mockServer != nil {
+		s.mockServer.Close()
+	}
+}
+
+func (s *SIGHUPSuite) InitServer(configPath, configDir string) {
+	cfg, err := config.Read(configPath, configDir)
+	s.Require().NoError(err)
+	cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
+
+	s.server, err = mcp.NewServer(mcp.Configuration{
+		StaticConfig: cfg,
+	})
+	s.Require().NoError(err)
+	// Set up SIGHUP handler
+	opts := &MCPServerOptions{
+		ConfigPath: configPath,
+		ConfigDir:  configDir,
+	}
+	opts.setupSIGHUPHandler(s.server)
+}
+
+func (s *SIGHUPSuite) TestSIGHUPReloadsConfigFromFile() {
+	// Create initial config file - start with only core toolset (no helm)
+	configPath := filepath.Join(s.tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = ["core", "config"]
+	`), 0644))
+	s.InitServer(configPath, "")
+
+	s.Run("helm tools are not initially available", func() {
+		s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list"))
+	})
+
+	// Modify the config file to add helm toolset
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = ["core", "config", "helm"]
+	`), 0644))
+
+	// Send SIGHUP to current process
+	s.Require().NoError(syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
+
+	s.Run("helm tools become available after SIGHUP", func() {
+		s.Require().Eventually(func() bool {
+			return slices.Contains(s.server.GetEnabledTools(), "helm_list")
+		}, 2*time.Second, 50*time.Millisecond)
+	})
+}
+
+func (s *SIGHUPSuite) TestSIGHUPReloadsFromDropInDirectory() {
+	// Create initial config file - with helm enabled
+	configPath := filepath.Join(s.tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = ["core", "config", "helm"]
+	`), 0644))
+
+	// Create initial drop-in file that removes helm
+	dropInPath := filepath.Join(s.dropInConfigDir, "10-override.toml")
+	s.Require().NoError(os.WriteFile(dropInPath, []byte(`
+		toolsets = ["core", "config"]
+	`), 0644))
+
+	s.InitServer(configPath, "")
+
+	s.Run("drop-in override removes helm from initial config", func() {
+		s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list"))
+	})
+
+	// Update drop-in file to add helm back
+	s.Require().NoError(os.WriteFile(dropInPath, []byte(`
+		toolsets = ["core", "config", "helm"]
+	`), 0644))
+
+	// Send SIGHUP
+	s.Require().NoError(syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
+
+	s.Run("helm tools become available after updating drop-in and sending SIGHUP", func() {
+		s.Require().Eventually(func() bool {
+			return slices.Contains(s.server.GetEnabledTools(), "helm_list")
+		}, 2*time.Second, 50*time.Millisecond)
+	})
+}
+
+func (s *SIGHUPSuite) TestSIGHUPWithInvalidConfigContinues() {
+	// Create initial config file - start with only core toolset (no helm)
+	configPath := filepath.Join(s.tempDir, "config.toml")
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = ["core", "config"]
+	`), 0644))
+	s.InitServer(configPath, "")
+
+	s.Run("helm tools are not initially available", func() {
+		s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list"))
+	})
+
+	// Write invalid TOML to config file
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = "not a valid array
+	`), 0644))
+
+	// Send SIGHUP - should not panic, should continue with old config
+	s.Require().NoError(syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
+
+	s.Run("logs error when config is invalid", func() {
+		s.Require().Eventually(func() bool {
+			return strings.Contains(s.logBuffer.String(), "Failed to reload configuration")
+		}, 2*time.Second, 50*time.Millisecond)
+	})
+
+	s.Run("tools remain unchanged after failed reload", func() {
+		s.True(slices.Contains(s.server.GetEnabledTools(), "events_list"))
+		s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list"))
+	})
+
+	// Now fix the config and add helm
+	s.Require().NoError(os.WriteFile(configPath, []byte(`
+		toolsets = ["core", "config", "helm"]
+	`), 0644))
+
+	// Send another SIGHUP
+	s.Require().NoError(syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
+
+	s.Run("helm tools become available after fixing config and sending SIGHUP", func() {
+		s.Require().Eventually(func() bool {
+			return slices.Contains(s.server.GetEnabledTools(), "helm_list")
+		}, 2*time.Second, 50*time.Millisecond)
+	})
+}
+
+func (s *SIGHUPSuite) TestSIGHUPWithConfigDirOnly() {
+	// Create initial drop-in file without helm
+	dropInPath := filepath.Join(s.dropInConfigDir, "10-settings.toml")
+	s.Require().NoError(os.WriteFile(dropInPath, []byte(`
+		toolsets = ["core", "config"]
+	`), 0644))
+
+	s.InitServer("", s.dropInConfigDir)
+
+	s.Run("helm tools are not initially available", func() {
+		s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list"))
+	})
+
+	// Update drop-in file to add helm
+	s.Require().NoError(os.WriteFile(dropInPath, []byte(`
+		toolsets = ["core", "config", "helm"]
+	`), 0644))
+
+	// Send SIGHUP
+	s.Require().NoError(syscall.Kill(syscall.Getpid(), syscall.SIGHUP))
+
+	s.Run("helm tools become available after SIGHUP with config-dir only", func() {
+		s.Require().Eventually(func() bool {
+			return slices.Contains(s.server.GetEnabledTools(), "helm_list")
+		}, 2*time.Second, 50*time.Millisecond)
+	})
+}
+
+func TestSIGHUP(t *testing.T) {
+	suite.Run(t, new(SIGHUPSuite))
+}
diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index d85eb37..c1db51a 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 read and merge config files: failed to read config 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/kubernetes/provider_watch_test.go b/pkg/kubernetes/provider_watch_test.go
index 95e6caa..42ad229 100644
--- a/pkg/kubernetes/provider_watch_test.go
+++ b/pkg/kubernetes/provider_watch_test.go
@@ -29,6 +29,7 @@ func (s *ProviderWatchTargetsTestSuite) SetupTest() {
 
 	s.T().Setenv("CLUSTER_STATE_POLL_INTERVAL_MS", "100")
 	s.T().Setenv("CLUSTER_STATE_DEBOUNCE_WINDOW_MS", "50")
+	s.T().Setenv("KUBECONFIG_DEBOUNCE_WINDOW_MS", "50")
 
 	// Add multiple fake contexts to allow testing of context changes
 	s.kubeconfig = s.mockServer.Kubeconfig()
diff --git a/pkg/kubernetes/watcher/kubeconfig_test.go b/pkg/kubernetes/watcher/kubeconfig_test.go
index 6f3b47d..cbc425b 100644
--- a/pkg/kubernetes/watcher/kubeconfig_test.go
+++ b/pkg/kubernetes/watcher/kubeconfig_test.go
@@ -23,6 +23,8 @@ type KubeconfigTestSuite struct {
 }
 
 func (s *KubeconfigTestSuite) SetupTest() {
+	// Use a short debounce window for tests
+	s.T().Setenv("KUBECONFIG_DEBOUNCE_WINDOW_MS", "50")
 	s.kubeconfigFile = test.KubeconfigFile(s.T(), test.KubeConfigFake())
 	s.clientConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
 		&clientcmd.ClientConfigLoadingRules{ExplicitPath: s.kubeconfigFile},
@@ -38,8 +40,8 @@ func (s *KubeconfigTestSuite) TestNewKubeconfig() {
 			s.NotNil(watcher.ClientConfig)
 			s.Equal(s.clientConfig, watcher.ClientConfig)
 		})
-		s.Run("initializes with nil close function", func() {
-			s.Nil(watcher.close)
+		s.Run("initializes with started as false", func() {
+			s.False(watcher.started)
 		})
 	})
 }
@@ -59,7 +61,7 @@ func (s *KubeconfigTestSuite) TestWatch() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
 		// Modify the kubeconfig file
@@ -89,7 +91,7 @@ func (s *KubeconfigTestSuite) TestWatch() {
 		}), "Watch blocked when no kubeconfig files exist")
 	})
 
-	s.Run("handles multiple file changes", func() {
+	s.Run("handles multiple file changes with debouncing", func() {
 		watcher := NewKubeconfig(s.clientConfig)
 		s.T().Cleanup(watcher.Close)
 
@@ -103,10 +105,11 @@ func (s *KubeconfigTestSuite) TestWatch() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
-		// Modify the kubeconfig file multiple times and wait for each change
+		// Modify the kubeconfig file multiple times, waiting for each callback
+		// to ensure we're past the debounce window before the next write
 		for i := 0; i < 3; i++ {
 			expectedCount := int32(i + 1)
 			s.Require().NoError(clientcmd.WriteToFile(*test.KubeConfigFake(), s.kubeconfigFile))
@@ -132,7 +135,7 @@ func (s *KubeconfigTestSuite) TestWatch() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
 		// Modify the kubeconfig file
@@ -144,48 +147,46 @@ func (s *KubeconfigTestSuite) TestWatch() {
 		}), "timeout waiting for onChange callback")
 	})
 
-	s.Run("replaces previous watcher on subsequent Watch calls", func() {
+	s.Run("ignores subsequent Watch calls when already started", func() {
 		watcher := NewKubeconfig(s.clientConfig)
 		s.T().Cleanup(watcher.Close)
 
+		var firstWatcherActive atomic.Bool
 		var secondWatcherActive atomic.Bool
 
 		// Start first watcher
 		watcher.Watch(func() error {
+			firstWatcherActive.Store(true)
 			return nil
 		})
 
 		// Wait for the first watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for first watcher to be ready")
 
-		// Start second watcher (should close the first)
+		// Try to start second watcher (should be ignored since already started)
 		watcher.Watch(func() error {
 			secondWatcherActive.Store(true)
 			return nil
 		})
 
-		// Wait for the second watcher to be ready
-		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
-		}), "timeout waiting for second watcher to be ready")
-
 		// Modify the kubeconfig file
 		s.Require().NoError(clientcmd.WriteToFile(*test.KubeConfigFake(), s.kubeconfigFile))
 
-		// Wait for second watcher to trigger
+		// Wait for first watcher to trigger
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return secondWatcherActive.Load()
-		}), "timeout waiting for second watcher")
+			return firstWatcherActive.Load()
+		}), "timeout waiting for first watcher")
+
+		// Verify second watcher was never activated
+		s.False(secondWatcherActive.Load(), "second watcher should not be activated")
 	})
 }
 
 func (s *KubeconfigTestSuite) TestClose() {
-	s.Run("returns no error when close is nil", func() {
-		watcher := &Kubeconfig{
-			close: nil,
-		}
+	s.Run("does not panic when watcher not started", func() {
+		watcher := NewKubeconfig(s.clientConfig)
 
 		s.NotPanics(func() {
 			watcher.Close()
@@ -199,7 +200,7 @@ func (s *KubeconfigTestSuite) TestClose() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
 		s.NotPanics(func() {
@@ -218,7 +219,7 @@ func (s *KubeconfigTestSuite) TestClose() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
 		watcher.Close()
@@ -245,7 +246,7 @@ func (s *KubeconfigTestSuite) TestClose() {
 
 		// Wait for the watcher to be ready
 		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
-			return watcher.close != nil
+			return watcher.started
 		}), "timeout waiting for watcher to be ready")
 
 		s.NotPanics(func() {
@@ -253,6 +254,116 @@ func (s *KubeconfigTestSuite) TestClose() {
 			watcher.Close()
 		})
 	})
+
+	s.Run("handles close when stopCh is already closed", func() {
+		watcher := NewKubeconfig(s.clientConfig)
+
+		watcher.Watch(func() error { return nil })
+
+		// Wait for the watcher to be ready
+		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
+			return watcher.started
+		}), "timeout waiting for watcher to be ready")
+
+		// First close - normal operation
+		watcher.Close()
+
+		// Second close - should hit the "case <-w.stopCh" branch (already closed)
+		s.NotPanics(func() {
+			watcher.Close()
+		})
+
+		// Verify watcher is in stopped state
+		s.False(watcher.started, "watcher should be stopped after close")
+	})
+
+	s.Run("handles close with nil channels", func() {
+		watcher := &Kubeconfig{
+			ClientConfig: s.clientConfig,
+			stopCh:       nil,
+			stoppedCh:    nil,
+		}
+
+		s.NotPanics(func() {
+			watcher.Close()
+		})
+	})
+
+	s.Run("stops debounce timer on close", func() {
+		watcher := NewKubeconfig(s.clientConfig)
+
+		var callCount atomic.Int32
+		watcher.Watch(func() error {
+			callCount.Add(1)
+			return nil
+		})
+
+		// Wait for the watcher to be ready
+		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
+			return watcher.started
+		}), "timeout waiting for watcher to be ready")
+
+		// Trigger a file change to start the debounce timer
+		s.Require().NoError(clientcmd.WriteToFile(*test.KubeConfigFake(), s.kubeconfigFile))
+
+		// Close immediately before debounce timer fires
+		time.Sleep(10 * time.Millisecond) // Small delay to ensure event is received
+		watcher.Close()
+
+		countAfterClose := callCount.Load()
+
+		// Wait longer than debounce window to verify timer was stopped
+		time.Sleep(100 * time.Millisecond)
+
+		// The callback should not have been called after close
+		// (or at most once if it was already in flight)
+		s.LessOrEqual(callCount.Load(), countAfterClose+1,
+			"debounce timer should be stopped on close")
+	})
+
+	s.Run("can restart watcher after close", func() {
+		watcher := NewKubeconfig(s.clientConfig)
+
+		var firstCallbackTriggered atomic.Bool
+		watcher.Watch(func() error {
+			firstCallbackTriggered.Store(true)
+			return nil
+		})
+
+		// Wait for the watcher to be ready
+		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
+			return watcher.started
+		}), "timeout waiting for watcher to be ready")
+
+		// Close the watcher
+		watcher.Close()
+		s.False(watcher.started, "watcher should be stopped after close")
+
+		s.NotNil(watcher.stopCh, "stopCh should be recreated after close")
+		s.NotNil(watcher.stoppedCh, "stoppedCh should be recreated after close")
+
+		// Start a new watcher
+		var secondCallbackTriggered atomic.Bool
+		watcher.Watch(func() error {
+			secondCallbackTriggered.Store(true)
+			return nil
+		})
+
+		// Wait for the new watcher to be ready
+		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
+			return watcher.started
+		}), "timeout waiting for restarted watcher to be ready")
+
+		// Trigger a file change
+		s.Require().NoError(clientcmd.WriteToFile(*test.KubeConfigFake(), s.kubeconfigFile))
+
+		// Wait for callback
+		s.Require().NoError(test.WaitForCondition(kubeconfigTestTimeout, func() bool {
+			return secondCallbackTriggered.Load()
+		}), "timeout waiting for restarted watcher callback")
+
+		watcher.Close()
+	})
 }
 
 func TestKubeconfig(t *testing.T) {
diff --git a/pkg/mcp/events_test.go b/pkg/mcp/events_test.go
index 06d6495..f012f0e 100644
--- a/pkg/mcp/events_test.go
+++ b/pkg/mcp/events_test.go
@@ -32,7 +32,7 @@ func (s *EventsSuite) TestEventsList() {
 	s.Run("events_list (with events)", func() {
 		client := kubernetes.NewForConfigOrDie(envTestRestConfig)
 		for _, ns := range []string{"default", "ns-1"} {
-			_, _ = client.CoreV1().Events(ns).Create(s.T().Context(), &v1.Event{
+			_, eventCreateErr := client.CoreV1().Events(ns).Create(s.T().Context(), &v1.Event{
 				ObjectMeta: metav1.ObjectMeta{
 					Name: "an-event-in-" + ns,
 				},
@@ -45,6 +45,7 @@ func (s *EventsSuite) TestEventsList() {
 				Type:    "Normal",
 				Message: "The event message",
 			}, metav1.CreateOptions{})
+			s.Require().NoError(eventCreateErr, "failed to create event in namespace %s", ns)
 		}
 		s.Run("events_list()", func() {
 			toolResult, err := s.CallTool("events_list", map[string]interface{}{})
diff --git a/pkg/mcp/mcp_reload_test.go b/pkg/mcp/mcp_reload_test.go
new file mode 100644
index 0000000..ddb8f95
--- /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))
+}
diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go
index 005a74b..2784657 100644
--- a/pkg/mcp/resources_test.go
+++ b/pkg/mcp/resources_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/BurntSushi/toml"
 	"github.com/mark3labs/mcp-go/mcp"
 	"github.com/stretchr/testify/suite"
+	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/rbac/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
@@ -16,6 +17,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/client-go/dynamic"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/utils/ptr"
 	"sigs.k8s.io/yaml"
 )
 
@@ -605,6 +607,122 @@ func (s *ResourcesSuite) TestResourcesDeleteDenied() {
 	})
 }
 
+func (s *ResourcesSuite) TestResourcesScale() {
+	s.InitMcpClient()
+	kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
+	deploymentName := "deployment-to-scale"
+	_, _ = kc.AppsV1().Deployments("default").Create(s.T().Context(), &appsv1.Deployment{
+		ObjectMeta: metav1.ObjectMeta{Name: deploymentName},
+		Spec: appsv1.DeploymentSpec{
+			Replicas: ptr.To(int32(2)),
+			Selector: &metav1.LabelSelector{
+				MatchLabels: map[string]string{"app": deploymentName},
+			},
+			Template: corev1.PodTemplateSpec{
+				ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": deploymentName}},
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}},
+				},
+			},
+		},
+	}, metav1.CreateOptions{})
+
+	s.Run("resources_scale with missing apiVersion returns error", func() {
+		toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{})
+		s.Truef(toolResult.IsError, "call tool should fail")
+		s.Equalf("failed to get/update resource scale, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text,
+			"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
+	})
+	s.Run("resources_scale with missing kind returns error", func() {
+		toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1"})
+		s.Truef(toolResult.IsError, "call tool should fail")
+		s.Equalf("failed to get/update resource scale, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text,
+			"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
+	})
+	s.Run("resources_scale with missing name returns error", func() {
+		toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1", "kind": "Deployment"})
+		s.Truef(toolResult.IsError, "call tool should fail")
+		s.Equalf("failed to get/update resource scale, missing argument name", toolResult.Content[0].(mcp.TextContent).Text,
+			"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
+	})
+	s.Run("resources_scale get returns current scale", func() {
+		result, err := s.CallTool("resources_scale", map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"namespace":  "default",
+			"name":       deploymentName,
+		})
+		s.Run("no error", func() {
+			s.Nilf(err, "call tool failed %v", err)
+			s.Falsef(result.IsError, "call tool failed: %v", result.Content)
+		})
+		s.Run("returns scale yaml", func() {
+			content := result.Content[0].(mcp.TextContent).Text
+			s.Truef(strings.HasPrefix(content, "# Current resource scale (YAML) is below"),
+				"Expected success message, got %v", content)
+			var decodedScale unstructured.Unstructured
+			err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
+			s.Nilf(err, "invalid tool result content %v", err)
+			replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
+			s.Truef(found, "replicas not found in scale object")
+			s.Equalf(int64(2), replicas, "expected 2 replicas, got %d", replicas)
+		})
+	})
+	s.Run("resources_scale update changes the scale", func() {
+		result, err := s.CallTool("resources_scale", map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"namespace":  "default",
+			"name":       deploymentName,
+			"scale":      5,
+		})
+		s.Run("no error", func() {
+			s.Nilf(err, "call tool failed %v", err)
+			s.Falsef(result.IsError, "call tool failed: %v", result.Content)
+		})
+		s.Run("returns updated scale yaml", func() {
+			content := result.Content[0].(mcp.TextContent).Text
+			var decodedScale unstructured.Unstructured
+			err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
+			s.Nilf(err, "invalid tool result content %v", err)
+			replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
+			s.Truef(found, "replicas not found in scale object")
+			s.Equalf(int64(5), replicas, "expected 5 replicas after update, got %d", replicas)
+		})
+		s.Run("deployment was actually scaled", func() {
+			deployment, _ := kc.AppsV1().Deployments("default").Get(s.T().Context(), deploymentName, metav1.GetOptions{})
+			s.Equalf(int32(5), *deployment.Spec.Replicas, "expected 5 replicas in deployment, got %d", *deployment.Spec.Replicas)
+		})
+	})
+	s.Run("resources_scale with nonexistent resource returns error", func() {
+		toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"namespace":  "default",
+			"name":       "nonexistent-deployment",
+		})
+		s.Truef(toolResult.IsError, "call tool should fail")
+		s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "not found",
+			"expected not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
+	})
+	s.Run("resources_scale with resource that does not support scale subresource returns error", func() {
+		configMapName := "configmap-without-scale"
+		_, _ = kc.CoreV1().ConfigMaps("default").Create(s.T().Context(), &corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{Name: configMapName},
+			Data:       map[string]string{"key": "value"},
+		}, metav1.CreateOptions{})
+		toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
+			"apiVersion": "v1",
+			"kind":       "ConfigMap",
+			"namespace":  "default",
+			"name":       configMapName,
+		})
+		s.Truef(toolResult.IsError, "call tool should fail")
+		s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "the server could not find the requested resource",
+			"expected scale subresource not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
+	})
+}
+
 func TestResources(t *testing.T) {
 	suite.Run(t, new(ResourcesSuite))
 }
diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json
index c0a242b..de88b94 100644
--- a/pkg/mcp/testdata/toolsets-core-tools.json
+++ b/pkg/mcp/testdata/toolsets-core-tools.json
@@ -483,5 +483,45 @@
       ]
     },
     "name": "resources_list"
+  },
+  {
+    "annotations": {
+      "title": "Resources: Scale",
+      "destructiveHint": true,
+      "idempotentHint": true,
+      "openWorldHint": true
+    },
+    "description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+    "inputSchema": {
+      "type": "object",
+      "properties": {
+        "apiVersion": {
+          "description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+          "type": "string"
+        },
+        "kind": {
+          "description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+          "type": "string"
+        },
+        "name": {
+          "description": "Name of the resource",
+          "type": "string"
+        },
+        "namespace": {
+          "description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+          "type": "string"
+        },
+        "scale": {
+          "description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+          "type": "integer"
+        }
+      },
+      "required": [
+        "apiVersion",
+        "kind",
+        "name"
+      ]
+    },
+    "name": "resources_scale"
   }
 ]
diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
index a5292c9..2691d99 100644
--- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
+++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
@@ -760,5 +760,53 @@
       ]
     },
     "name": "resources_list"
+  },
+  {
+    "annotations": {
+      "title": "Resources: Scale",
+      "destructiveHint": true,
+      "idempotentHint": true,
+      "openWorldHint": true
+    },
+    "description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+    "inputSchema": {
+      "type": "object",
+      "properties": {
+        "apiVersion": {
+          "description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+          "type": "string"
+        },
+        "context": {
+          "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
+          "enum": [
+            "extra-cluster",
+            "fake-context"
+          ],
+          "type": "string"
+        },
+        "kind": {
+          "description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+          "type": "string"
+        },
+        "name": {
+          "description": "Name of the resource",
+          "type": "string"
+        },
+        "namespace": {
+          "description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+          "type": "string"
+        },
+        "scale": {
+          "description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+          "type": "integer"
+        }
+      },
+      "required": [
+        "apiVersion",
+        "kind",
+        "name"
+      ]
+    },
+    "name": "resources_scale"
   }
 ]
diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json
index 801521d..276e6a3 100644
--- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json
+++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json
@@ -680,5 +680,49 @@
       ]
     },
     "name": "resources_list"
+  },
+  {
+    "annotations": {
+      "title": "Resources: Scale",
+      "destructiveHint": true,
+      "idempotentHint": true,
+      "openWorldHint": true
+    },
+    "description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+    "inputSchema": {
+      "type": "object",
+      "properties": {
+        "apiVersion": {
+          "description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+          "type": "string"
+        },
+        "context": {
+          "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
+          "type": "string"
+        },
+        "kind": {
+          "description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+          "type": "string"
+        },
+        "name": {
+          "description": "Name of the resource",
+          "type": "string"
+        },
+        "namespace": {
+          "description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+          "type": "string"
+        },
+        "scale": {
+          "description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+          "type": "integer"
+        }
+      },
+      "required": [
+        "apiVersion",
+        "kind",
+        "name"
+      ]
+    },
+    "name": "resources_scale"
   }
 ]
diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json
index d0c25ac..370b960 100644
--- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json
+++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json
@@ -597,5 +597,45 @@
       ]
     },
     "name": "resources_list"
+  },
+  {
+    "annotations": {
+      "title": "Resources: Scale",
+      "destructiveHint": true,
+      "idempotentHint": true,
+      "openWorldHint": true
+    },
+    "description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+    "inputSchema": {
+      "type": "object",
+      "properties": {
+        "apiVersion": {
+          "description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+          "type": "string"
+        },
+        "kind": {
+          "description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+          "type": "string"
+        },
+        "name": {
+          "description": "Name of the resource",
+          "type": "string"
+        },
+        "namespace": {
+          "description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+          "type": "string"
+        },
+        "scale": {
+          "description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+          "type": "integer"
+        }
+      },
+      "required": [
+        "apiVersion",
+        "kind",
+        "name"
+      ]
+    },
+    "name": "resources_scale"
   }
 ]
diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json
index 9e76c9d..296c639 100644
--- a/pkg/mcp/testdata/toolsets-full-tools.json
+++ b/pkg/mcp/testdata/toolsets-full-tools.json
@@ -584,5 +584,45 @@
       ]
     },
     "name": "resources_list"
+  },
+  {
+    "annotations": {
+      "title": "Resources: Scale",
+      "destructiveHint": true,
+      "idempotentHint": true,
+      "openWorldHint": true
+    },
+    "description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
+    "inputSchema": {
+      "type": "object",
+      "properties": {
+        "apiVersion": {
+          "description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
+          "type": "string"
+        },
+        "kind": {
+          "description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
+          "type": "string"
+        },
+        "name": {
+          "description": "Name of the resource",
+          "type": "string"
+        },
+        "namespace": {
+          "description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
+          "type": "string"
+        },
+        "scale": {
+          "description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
+          "type": "integer"
+        }
+      },
+      "required": [
+        "apiVersion",
+        "kind",
+        "name"
+      ]
+    },
+    "name": "resources_scale"
   }
 ]