Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,103 @@ 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. |
| `--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. |
| `--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.
Expand Down
150 changes: 139 additions & 11 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/BurntSushi/toml"
"k8s.io/klog/v2"
)

const (
Expand Down Expand Up @@ -95,26 +98,151 @@ func withDirPath(path string) ReadConfigOpt {
}
}

// Read reads the toml file and returns the StaticConfig, with any opts applied.
func Read(configPath string, opts ...ReadConfigOpt) (*StaticConfig, error) {
configData, err := os.ReadFile(configPath)
// Read reads the toml file, applies drop-in configs from configDir (if provided),
// and returns the StaticConfig with any opts applied.
// Loading order: defaults → main config file → drop-in files (lexically sorted)
func Read(configPath string, configDir string, opts ...ReadConfigOpt) (*StaticConfig, error) {
// Start with defaults
cfg := Default()

// Get the absolute dir path for the main config file
var dirPath string
if configPath != "" {
absPath, err := filepath.Abs(configPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path to config file: %w", err)
}
dirPath = filepath.Dir(absPath)

// Load main config file
klog.V(2).Infof("Loading main config from: %s", configPath)
if err := mergeConfigFile(cfg, configPath, append(opts, withDirPath(dirPath))...); err != nil {
return nil, fmt.Errorf("failed to load main config file %s: %w", configPath, err)
}
}

// Load drop-in config files if directory is specified
if configDir != "" {
if err := loadDropInConfigs(cfg, configDir, append(opts, withDirPath(dirPath))...); err != nil {
return nil, fmt.Errorf("failed to load drop-in configs from %s: %w", configDir, err)
}
}

return cfg, nil
}

// mergeConfigFile reads a config file and merges its values into the target config.
// Values present in the file will overwrite existing values in cfg.
// Values not present in the file will remain unchanged in cfg.
func mergeConfigFile(cfg *StaticConfig, filePath string, opts ...ReadConfigOpt) error {
configData, err := os.ReadFile(filePath)
if err != nil {
return nil, err
return err
}

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

cfg, err := ReadToml(configData, append(opts, withDirPath(dirPath))...)
for _, opt := range opts {
opt(cfg)
}

ctx := withConfigDirPath(context.Background(), cfg.configDirPath)

cfg.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, cfg.ClusterProviderConfigs)
if err != nil {
return nil, err
return err
}

return cfg, nil
cfg.parsedToolsetConfigs, err = toolsetConfigRegistry.parse(ctx, md, cfg.ToolsetConfigs)
if err != nil {
return err
}

return nil
}

// loadDropInConfigs loads and merges config files from a drop-in directory.
// Files are processed in lexical (alphabetical) order.
// Only files with .toml extension are processed; dotfiles are ignored.
func loadDropInConfigs(cfg *StaticConfig, dropInDir string, opts ...ReadConfigOpt) error {
// Check if directory exists
info, err := os.Stat(dropInDir)
if err != nil {
if os.IsNotExist(err) {
klog.V(2).Infof("Drop-in config directory does not exist, skipping: %s", dropInDir)
return nil
}
return fmt.Errorf("failed to stat drop-in directory: %w", err)
}

if !info.IsDir() {
return fmt.Errorf("drop-in config path is not a directory: %s", dropInDir)
}

// Get all .toml files in the directory
files, err := getSortedConfigFiles(dropInDir)
if err != nil {
return err
}

if len(files) == 0 {
klog.V(2).Infof("No drop-in config files found in: %s", dropInDir)
return nil
}

klog.V(2).Infof("Loading %d drop-in config file(s) from: %s", len(files), dropInDir)

// Merge each file in order
for _, file := range files {
klog.V(3).Infof(" - Merging drop-in config: %s", filepath.Base(file))
if err := mergeConfigFile(cfg, file, opts...); err != nil {
return fmt.Errorf("failed to merge drop-in config %s: %w", file, err)
}
}

return nil
}

// getSortedConfigFiles returns a sorted list of .toml files in the specified directory.
// Dotfiles (starting with '.') and non-.toml files are ignored.
// Files are sorted lexically (alphabetically) by filename.
func getSortedConfigFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}

var files []string
for _, entry := range entries {
// Skip directories
if entry.IsDir() {
continue
}

name := entry.Name()

// Skip dotfiles
if strings.HasPrefix(name, ".") {
klog.V(4).Infof("Skipping dotfile: %s", name)
continue
}

// Only process .toml files
if !strings.HasSuffix(name, ".toml") {
klog.V(4).Infof("Skipping non-.toml file: %s", name)
continue
}

files = append(files, filepath.Join(dir, name))
}

// Sort lexically
sort.Strings(files)

return files, nil
}

// ReadToml reads the toml data and returns the StaticConfig, with any opts applied
Expand Down
Loading