Skip to content

Commit 47f2db7

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 3a03479 commit 47f2db7

File tree

9 files changed

+733
-55
lines changed

9 files changed

+733
-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: 139 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 (
@@ -95,26 +98,151 @@ func withDirPath(path string) ReadConfigOpt {
9598
}
9699
}
97100

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

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

112-
cfg, err := ReadToml(configData, append(opts, withDirPath(dirPath))...)
148+
for _, opt := range opts {
149+
opt(cfg)
150+
}
151+
152+
ctx := withConfigDirPath(context.Background(), cfg.configDirPath)
153+
154+
cfg.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, cfg.ClusterProviderConfigs)
113155
if err != nil {
114-
return nil, err
156+
return err
115157
}
116158

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

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

0 commit comments

Comments
 (0)