Skip to content

Commit 8b5f7c4

Browse files
committed
feat(config): add drop-in configuration support with dynamic reload
Add support for flexible configuration management through both main config files and drop-in directories - Load config from --config (main file) and --config-dir (drop-in directory) - Drop-in files processed in alphabetical order for predictable overrides - Partial configuration support - drop-in files can override specific values - Dynamic configuration reload via SIGHUP signal - SIGHUP support requires either --config or --config-dir flag at startup Signed-off-by: Nader Ziada <[email protected]>
1 parent 77b4baf commit 8b5f7c4

File tree

9 files changed

+734
-55
lines changed

9 files changed

+734
-55
lines changed

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,103 @@ uvx kubernetes-mcp-server@latest --help
189189
|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
190190
| `--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 . |
191191
| `--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). |
192+
| `--config` | (Optional) Path to the main TOML configuration file. See [Drop-in Configuration](#drop-in-configuration) section below for details. |
193+
| `--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. |
192194
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
193195
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
194196
| `--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. |
195197
| `--disable-destructive` | If set, the MCP server will disable all destructive operations (delete, update, etc.) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without accidentally making changes. This option has no effect when `--read-only` is used. |
196198
| `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
197199
| `--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. |
198200

201+
### Drop-in Configuration <a id="drop-in-configuration"></a>
202+
203+
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).
204+
205+
#### Configuration Loading Order
206+
207+
Configuration values are loaded and merged in the following order (later sources override earlier ones):
208+
209+
1. **Internal Defaults** - Always loaded (hardcoded default values)
210+
2. **Main Configuration File** - Optional, loaded via `--config` flag
211+
3. **Drop-in Files** - Optional, loaded from `--config-dir` in **lexical (alphabetical) order**
212+
213+
#### How Drop-in Files Work
214+
215+
- **File Naming**: Use numeric prefixes to control loading order (e.g., `00-base.toml`, `10-cluster.toml`, `99-override.toml`)
216+
- **File Extension**: Only `.toml` files are processed; dotfiles (starting with `.`) are ignored
217+
- **Partial Configuration**: Drop-in files can contain only a subset of configuration options
218+
- **Merge Behavior**: Values present in a drop-in file override previous values; missing values are preserved
219+
220+
#### Dynamic Configuration Reload
221+
222+
To reload configuration after modifying config files, send a `SIGHUP` signal to the running server process.
223+
224+
**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.
225+
226+
**How to reload:**
227+
228+
```shell
229+
# Find the process ID
230+
ps aux | grep kubernetes-mcp-server
231+
232+
# Send SIGHUP to reload configuration
233+
kill -HUP <pid>
234+
235+
# Or use pkill
236+
pkill -HUP kubernetes-mcp-server
237+
```
238+
239+
The server will:
240+
- Reload the main config file and all drop-in files
241+
- Update configuration values (log level, output format, etc.)
242+
- Rebuild the toolset registry with new tool configurations
243+
- Log the reload status
244+
245+
**Note**: Changing `kubeconfig` or cluster-related settings requires a server restart. Only tool configurations, log levels, and output formats can be reloaded dynamically.
246+
247+
**Note**: SIGHUP reload is not available on Windows. On Windows, restart the server to reload configuration.
248+
249+
#### Example: Using Both Config Methods
250+
251+
**Command:**
252+
```shell
253+
kubernetes-mcp-server --config /etc/kubernetes-mcp-server/config.toml \
254+
--config-dir /etc/kubernetes-mcp-server/config.d/
255+
```
256+
257+
**Directory structure:**
258+
```
259+
/etc/kubernetes-mcp-server/
260+
├── config.toml # Main configuration
261+
└── config.d/
262+
├── 00-base.toml # Base overrides
263+
├── 10-toolsets.toml # Toolset-specific config
264+
└── 99-local.toml # Local overrides
265+
```
266+
267+
**Example drop-in file** (`10-toolsets.toml`):
268+
```toml
269+
# Override only the toolsets - all other config preserved
270+
toolsets = ["core", "config", "helm", "logs"]
271+
```
272+
273+
**Example drop-in file** (`99-local.toml`):
274+
```toml
275+
# Local development overrides
276+
log_level = 9
277+
read_only = true
278+
```
279+
280+
**To apply changes:**
281+
```shell
282+
# Edit config files
283+
vim /etc/kubernetes-mcp-server/config.d/99-local.toml
284+
285+
# Reload without restarting
286+
pkill -HUP kubernetes-mcp-server
287+
```
288+
199289
## 🛠️ Tools and Functionalities <a id="tools-and-functionalities"></a>
200290

201291
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.

pkg/config/config.go

Lines changed: 140 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"sort"
10+
"strings"
911

1012
"github.com/BurntSushi/toml"
13+
"k8s.io/klog/v2"
1114
)
1215

1316
const (
@@ -96,26 +99,152 @@ func WithDirPath(path string) ReadConfigOpt {
9699
}
97100
}
98101

99-
// Read reads the toml file and returns the StaticConfig, with any opts applied.
100-
func Read(configPath string, opts ...ReadConfigOpt) (*StaticConfig, error) {
101-
configData, err := os.ReadFile(configPath)
102+
// Read reads the toml file, applies drop-in configs from configDir (if provided),
103+
// and returns the StaticConfig with any opts applied.
104+
// Loading order: defaults → main config file → drop-in files (lexically sorted)
105+
func Read(configPath string, configDir string, opts ...ReadConfigOpt) (*StaticConfig, error) {
106+
// Start with defaults
107+
cfg := Default()
108+
109+
// Get the absolute dir path for the main config file
110+
var dirPath string
111+
if configPath != "" {
112+
// get and save the absolute dir path to the config file, so that other config parsers can use it
113+
absPath, err := filepath.Abs(configPath)
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
116+
}
117+
dirPath = filepath.Dir(absPath)
118+
119+
// Load main config file
120+
klog.V(2).Infof("Loading main config from: %s", configPath)
121+
if err := mergeConfigFile(cfg, configPath, append(opts, WithDirPath(dirPath))...); err != nil {
122+
return nil, fmt.Errorf("failed to load main config file %s: %w", configPath, err)
123+
}
124+
}
125+
126+
// Load drop-in config files if directory is specified
127+
if configDir != "" {
128+
if err := loadDropInConfigs(cfg, configDir, append(opts, WithDirPath(dirPath))...); err != nil {
129+
return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", configDir, err)
130+
}
131+
}
132+
133+
return cfg, nil
134+
}
135+
136+
// mergeConfigFile reads a config file and merges its values into the target config.
137+
// Values present in the file will overwrite existing values in cfg.
138+
// Values not present in the file will remain unchanged in cfg.
139+
func mergeConfigFile(cfg *StaticConfig, filePath string, opts ...ReadConfigOpt) error {
140+
configData, err := os.ReadFile(filePath)
102141
if err != nil {
103-
return nil, err
142+
return err
104143
}
105144

106-
// get and save the absolute dir path to the config file, so that other config parsers can use it
107-
absPath, err := filepath.Abs(configPath)
145+
md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(cfg)
108146
if err != nil {
109-
return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
147+
return fmt.Errorf("failed to decode TOML: %w", err)
110148
}
111-
dirPath := filepath.Dir(absPath)
112149

113-
cfg, err := ReadToml(configData, append(opts, WithDirPath(dirPath))...)
150+
for _, opt := range opts {
151+
opt(cfg)
152+
}
153+
154+
ctx := withConfigDirPath(context.Background(), cfg.configDirPath)
155+
156+
cfg.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, cfg.ClusterProviderConfigs)
114157
if err != nil {
115-
return nil, err
158+
return err
116159
}
117160

118-
return cfg, nil
161+
cfg.parsedToolsetConfigs, err = toolsetConfigRegistry.parse(ctx, md, cfg.ToolsetConfigs)
162+
if err != nil {
163+
return err
164+
}
165+
166+
return nil
167+
}
168+
169+
// loadDropInConfigs loads and merges config files from a drop-in directory.
170+
// Files are processed in lexical (alphabetical) order.
171+
// Only files with .toml extension are processed; dotfiles are ignored.
172+
func loadDropInConfigs(cfg *StaticConfig, dropInDir string, opts ...ReadConfigOpt) error {
173+
// Check if directory exists
174+
info, err := os.Stat(dropInDir)
175+
if err != nil {
176+
if os.IsNotExist(err) {
177+
klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInDir)
178+
return nil
179+
}
180+
return fmt.Errorf("failed to stat drop-in directory: %w", err)
181+
}
182+
183+
if !info.IsDir() {
184+
return fmt.Errorf("drop-in config path is not a directory: %s", dropInDir)
185+
}
186+
187+
// Get all .toml files in the directory
188+
files, err := getSortedConfigFiles(dropInDir)
189+
if err != nil {
190+
return err
191+
}
192+
193+
if len(files) == 0 {
194+
klog.V(2).Infof("No drop-in config files found in: %s", dropInDir)
195+
return nil
196+
}
197+
198+
klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(files), dropInDir)
199+
200+
// Merge each file in order
201+
for _, file := range files {
202+
klog.V(3).Infof(" - Merging drop-in config: %s", filepath.Base(file))
203+
if err := mergeConfigFile(cfg, file, opts...); err != nil {
204+
return fmt.Errorf("failed to merge drop-in config %s: %w", file, err)
205+
}
206+
}
207+
208+
return nil
209+
}
210+
211+
// getSortedConfigFiles returns a sorted list of .toml files in the specified directory.
212+
// Dotfiles (starting with '.') and non-.toml files are ignored.
213+
// Files are sorted lexically (alphabetically) by filename.
214+
func getSortedConfigFiles(dir string) ([]string, error) {
215+
entries, err := os.ReadDir(dir)
216+
if err != nil {
217+
return nil, fmt.Errorf("failed to read directory: %w", err)
218+
}
219+
220+
var files []string
221+
for _, entry := range entries {
222+
// Skip directories
223+
if entry.IsDir() {
224+
continue
225+
}
226+
227+
name := entry.Name()
228+
229+
// Skip dotfiles
230+
if strings.HasPrefix(name, ".") {
231+
klog.V(4).Infof("Skipping dotfile: %s", name)
232+
continue
233+
}
234+
235+
// Only process .toml files
236+
if !strings.HasSuffix(name, ".toml") {
237+
klog.V(4).Infof("Skipping non-.toml file: %s", name)
238+
continue
239+
}
240+
241+
files = append(files, filepath.Join(dir, name))
242+
}
243+
244+
// Sort lexically
245+
sort.Strings(files)
246+
247+
return files, nil
119248
}
120249

121250
// ReadToml reads the toml data and returns the StaticConfig, with any opts applied

0 commit comments

Comments
 (0)