Skip to content

Commit ba40e69

Browse files
committed
feat(toolsets): add support for multiple toolsets in configuration
Users can now enable or disable different toolsets either by providing a command-line flag or by setting the toolsets array field in the TOML configuration. Downstream Kubernetes API developers can declare toolsets for their APIs by creating a new nested package in pkg/toolsets and registering it in pkg/mcp/modules.go Signed-off-by: Marc Nuri <[email protected]>
1 parent 209e843 commit ba40e69

32 files changed

+477
-283
lines changed

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/containers/kubernetes-mcp-server?sort=semver)](https://github.com/containers/kubernetes-mcp-server/releases/latest)
77
[![Build](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml/badge.svg)](https://github.com/containers/kubernetes-mcp-server/actions/workflows/build.yaml)
88

9-
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools) | [🧑‍💻 Development](#development)
9+
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🛠️ Tools](#tools-and-functionalities) | [🧑‍💻 Development](#development)
1010

1111
https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
1212

@@ -183,8 +183,27 @@ uvx kubernetes-mcp-server@latest --help
183183
| `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") |
184184
| `--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. |
185185
| `--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. |
186+
| `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. |
187+
188+
## 🛠️ Tools and Functionalities <a id="tools-and-functionalities"></a>
189+
190+
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.
191+
This allows you to control which Kubernetes functionalities are available to your AI tools.
192+
Enabling only the toolsets you need can help reduce the context size and improve the LLM's tool selection accuracy.
193+
194+
### Available Toolsets
195+
196+
The following sets of tools are available (all on by default):
197+
198+
| Toolset | Description |
199+
|----------|-------------------------------------------------------------------------------------|
200+
| `core` | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
201+
| `config` | View and manage the current local Kubernetes configuration (kubeconfig) |
202+
| `helm` | Tools for managing Helm charts and releases |
203+
204+
### Tools
205+
186206

187-
## 🛠️ Tools <a id="tools"></a>
188207

189208
### `configuration_view`
190209

internal/test/test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package test
2+
3+
func Must[T any](v T, err error) T {
4+
if err != nil {
5+
panic(err)
6+
}
7+
return v
8+
}

pkg/config/config.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type StaticConfig struct {
2020
ReadOnly bool `toml:"read_only,omitempty"`
2121
// When true, disable tools annotated with destructiveHint=true
2222
DisableDestructive bool `toml:"disable_destructive,omitempty"`
23+
Toolsets []string `toml:"toolsets,omitempty"`
2324
EnabledTools []string `toml:"enabled_tools,omitempty"`
2425
DisabledTools []string `toml:"disabled_tools,omitempty"`
2526

@@ -50,22 +51,32 @@ type StaticConfig struct {
5051
ServerURL string `toml:"server_url,omitempty"`
5152
}
5253

54+
func Default() *StaticConfig {
55+
return &StaticConfig{
56+
ListOutput: "table",
57+
Toolsets: []string{"core", "config", "helm"},
58+
}
59+
}
60+
5361
type GroupVersionKind struct {
5462
Group string `toml:"group"`
5563
Version string `toml:"version"`
5664
Kind string `toml:"kind,omitempty"`
5765
}
5866

59-
// ReadConfig reads the toml file and returns the StaticConfig.
60-
func ReadConfig(configPath string) (*StaticConfig, error) {
67+
// Read reads the toml file and returns the StaticConfig.
68+
func Read(configPath string) (*StaticConfig, error) {
6169
configData, err := os.ReadFile(configPath)
6270
if err != nil {
6371
return nil, err
6472
}
73+
return ReadToml(configData)
74+
}
6575

66-
var config *StaticConfig
67-
err = toml.Unmarshal(configData, &config)
68-
if err != nil {
76+
// ReadToml reads the toml data and returns the StaticConfig.
77+
func ReadToml(configData []byte) (*StaticConfig, error) {
78+
config := Default()
79+
if err := toml.Unmarshal(configData, config); err != nil {
6980
return nil, err
7081
}
7182
return config, nil

pkg/config/config_test.go

Lines changed: 130 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,175 @@
11
package config
22

33
import (
4+
"errors"
5+
"io/fs"
46
"os"
57
"path/filepath"
68
"strings"
79
"testing"
10+
11+
"github.com/stretchr/testify/suite"
812
)
913

10-
func TestReadConfigMissingFile(t *testing.T) {
11-
config, err := ReadConfig("non-existent-config.toml")
12-
t.Run("returns error for missing file", func(t *testing.T) {
13-
if err == nil {
14-
t.Fatal("Expected error for missing file, got nil")
15-
}
16-
if config != nil {
17-
t.Fatalf("Expected nil config for missing file, got %v", config)
18-
}
14+
type ConfigSuite struct {
15+
suite.Suite
16+
}
17+
18+
func (s *ConfigSuite) TestReadConfigMissingFile() {
19+
config, err := Read("non-existent-config.toml")
20+
s.Run("returns error for missing file", func() {
21+
s.Require().NotNil(err, "Expected error for missing file, got nil")
22+
s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
23+
})
24+
s.Run("returns nil config for missing file", func() {
25+
s.Nil(config, "Expected nil config for missing file")
1926
})
2027
}
2128

22-
func TestReadConfigInvalid(t *testing.T) {
23-
invalidConfigPath := writeConfig(t, `
24-
[[denied_resources]]
25-
group = "apps"
26-
version = "v1"
27-
kind = "Deployment"
28-
[[denied_resources]]
29-
group = "rbac.authorization.k8s.io"
30-
version = "v1"
31-
kind = "Role
32-
`)
29+
func (s *ConfigSuite) TestReadConfigInvalid() {
30+
invalidConfigPath := s.writeConfig(`
31+
[[denied_resources]]
32+
group = "apps"
33+
version = "v1"
34+
kind = "Deployment"
35+
[[denied_resources]]
36+
group = "rbac.authorization.k8s.io"
37+
version = "v1"
38+
kind = "Role
39+
`)
3340

34-
config, err := ReadConfig(invalidConfigPath)
35-
t.Run("returns error for invalid file", func(t *testing.T) {
36-
if err == nil {
37-
t.Fatal("Expected error for invalid file, got nil")
38-
}
39-
if config != nil {
40-
t.Fatalf("Expected nil config for invalid file, got %v", config)
41-
}
41+
config, err := Read(invalidConfigPath)
42+
s.Run("returns error for invalid file", func() {
43+
s.Require().NotNil(err, "Expected error for invalid file, got nil")
4244
})
43-
t.Run("error message contains toml error with line number", func(t *testing.T) {
45+
s.Run("error message contains toml error with line number", func() {
4446
expectedError := "toml: line 9"
45-
if err != nil && !strings.HasPrefix(err.Error(), expectedError) {
46-
t.Fatalf("Expected error message '%s' to contain line number, got %v", expectedError, err)
47-
}
47+
s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
48+
})
49+
s.Run("returns nil config for invalid file", func() {
50+
s.Nil(config, "Expected nil config for missing file")
4851
})
4952
}
5053

51-
func TestReadConfigValid(t *testing.T) {
52-
validConfigPath := writeConfig(t, `
53-
log_level = 1
54-
port = "9999"
55-
sse_base_url = "https://example.com"
56-
kubeconfig = "./path/to/config"
57-
list_output = "yaml"
58-
read_only = true
59-
disable_destructive = true
54+
func (s *ConfigSuite) TestReadConfigValid() {
55+
validConfigPath := s.writeConfig(`
56+
log_level = 1
57+
port = "9999"
58+
sse_base_url = "https://example.com"
59+
kubeconfig = "./path/to/config"
60+
list_output = "yaml"
61+
read_only = true
62+
disable_destructive = true
6063
61-
denied_resources = [
62-
{group = "apps", version = "v1", kind = "Deployment"},
63-
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
64-
]
64+
toolsets = ["core", "config", "helm", "metrics"]
65+
66+
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
67+
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
6568
66-
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
67-
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
68-
`)
69+
denied_resources = [
70+
{group = "apps", version = "v1", kind = "Deployment"},
71+
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
72+
]
73+
74+
`)
6975

70-
config, err := ReadConfig(validConfigPath)
71-
t.Run("reads and unmarshalls file", func(t *testing.T) {
72-
if err != nil {
73-
t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
74-
}
75-
if config == nil {
76-
t.Fatal("ReadConfig returned a nil config for a valid file")
77-
}
76+
config, err := Read(validConfigPath)
77+
s.Require().NotNil(config)
78+
s.Run("reads and unmarshalls file", func() {
79+
s.Nil(err, "Expected nil error for valid file")
80+
s.Require().NotNil(config, "Expected non-nil config for valid file")
7881
})
79-
t.Run("denied resources are parsed correctly", func(t *testing.T) {
80-
if len(config.DeniedResources) != 2 {
81-
t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
82-
}
83-
if config.DeniedResources[0].Group != "apps" ||
84-
config.DeniedResources[0].Version != "v1" ||
85-
config.DeniedResources[0].Kind != "Deployment" {
86-
t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
87-
}
82+
s.Run("log_level parsed correctly", func() {
83+
s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
8884
})
89-
t.Run("log_level parsed correctly", func(t *testing.T) {
90-
if config.LogLevel != 1 {
91-
t.Fatalf("Unexpected log level: %v", config.LogLevel)
92-
}
85+
s.Run("port parsed correctly", func() {
86+
s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", config.Port)
9387
})
94-
t.Run("port parsed correctly", func(t *testing.T) {
95-
if config.Port != "9999" {
96-
t.Fatalf("Unexpected port value: %v", config.Port)
97-
}
88+
s.Run("sse_base_url parsed correctly", func() {
89+
s.Equalf("https://example.com", config.SSEBaseURL, "Expected SSEBaseURL to be https://example.com, got %s", config.SSEBaseURL)
9890
})
99-
t.Run("sse_base_url parsed correctly", func(t *testing.T) {
100-
if config.SSEBaseURL != "https://example.com" {
101-
t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
102-
}
91+
s.Run("kubeconfig parsed correctly", func() {
92+
s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", config.KubeConfig)
10393
})
104-
t.Run("kubeconfig parsed correctly", func(t *testing.T) {
105-
if config.KubeConfig != "./path/to/config" {
106-
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
107-
}
94+
s.Run("list_output parsed correctly", func() {
95+
s.Equalf("yaml", config.ListOutput, "Expected ListOutput to be yaml, got %s", config.ListOutput)
96+
})
97+
s.Run("read_only parsed correctly", func() {
98+
s.Truef(config.ReadOnly, "Expected ReadOnly to be true, got %v", config.ReadOnly)
99+
})
100+
s.Run("disable_destructive parsed correctly", func() {
101+
s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive)
108102
})
109-
t.Run("list_output parsed correctly", func(t *testing.T) {
110-
if config.ListOutput != "yaml" {
111-
t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
103+
s.Run("toolsets", func() {
104+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
105+
for _, toolset := range []string{"core", "config", "helm", "metrics"} {
106+
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
112107
}
113108
})
114-
t.Run("read_only parsed correctly", func(t *testing.T) {
115-
if !config.ReadOnly {
116-
t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
109+
s.Run("enabled_tools", func() {
110+
s.Require().Lenf(config.EnabledTools, 8, "Expected 8 enabled tools, got %d", len(config.EnabledTools))
111+
for _, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
112+
s.Containsf(config.EnabledTools, tool, "Expected enabled tools to contain %s", tool)
117113
}
118114
})
119-
t.Run("disable_destructive parsed correctly", func(t *testing.T) {
120-
if !config.DisableDestructive {
121-
t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
115+
s.Run("disabled_tools", func() {
116+
s.Require().Lenf(config.DisabledTools, 5, "Expected 5 disabled tools, got %d", len(config.DisabledTools))
117+
for _, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
118+
s.Containsf(config.DisabledTools, tool, "Expected disabled tools to contain %s", tool)
122119
}
123120
})
124-
t.Run("enabled_tools parsed correctly", func(t *testing.T) {
125-
if len(config.EnabledTools) != 8 {
126-
t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)
121+
s.Run("denied_resources", func() {
122+
s.Require().Lenf(config.DeniedResources, 2, "Expected 2 denied resources, got %d", len(config.DeniedResources))
123+
s.Run("contains apps/v1/Deployment", func() {
124+
s.Contains(config.DeniedResources, GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
125+
"Expected denied resources to contain apps/v1/Deployment")
126+
})
127+
s.Run("contains rbac.authorization.k8s.io/v1/Role", func() {
128+
s.Contains(config.DeniedResources, GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"},
129+
"Expected denied resources to contain rbac.authorization.k8s.io/v1/Role")
130+
})
131+
})
132+
}
127133

128-
}
129-
for i, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
130-
if config.EnabledTools[i] != tool {
131-
t.Errorf("Expected enabled tool %d to be %s, got %s", i, tool, config.EnabledTools[i])
132-
}
133-
}
134+
func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
135+
validConfigPath := s.writeConfig(`
136+
port = "1337"
137+
`)
138+
139+
config, err := Read(validConfigPath)
140+
s.Require().NotNil(config)
141+
s.Run("reads and unmarshalls file", func() {
142+
s.Nil(err, "Expected nil error for valid file")
143+
s.Require().NotNil(config, "Expected non-nil config for valid file")
134144
})
135-
t.Run("disabled_tools parsed correctly", func(t *testing.T) {
136-
if len(config.DisabledTools) != 5 {
137-
t.Fatalf("Unexpected disabled tools: %v", config.DisabledTools)
138-
}
139-
for i, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
140-
if config.DisabledTools[i] != tool {
141-
t.Errorf("Expected disabled tool %d to be %s, got %s", i, tool, config.DisabledTools[i])
142-
}
145+
s.Run("log_level defaulted correctly", func() {
146+
s.Equalf(0, config.LogLevel, "Expected LogLevel to be 0, got %d", config.LogLevel)
147+
})
148+
s.Run("port parsed correctly", func() {
149+
s.Equalf("1337", config.Port, "Expected Port to be 9999, got %s", config.Port)
150+
})
151+
s.Run("list_output defaulted correctly", func() {
152+
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
153+
})
154+
s.Run("toolsets defaulted correctly", func() {
155+
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
156+
for _, toolset := range []string{"core", "config", "helm"} {
157+
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
143158
}
144159
})
145160
}
146161

147-
func writeConfig(t *testing.T, content string) string {
148-
t.Helper()
149-
tempDir := t.TempDir()
162+
func (s *ConfigSuite) writeConfig(content string) string {
163+
s.T().Helper()
164+
tempDir := s.T().TempDir()
150165
path := filepath.Join(tempDir, "config.toml")
151166
err := os.WriteFile(path, []byte(content), 0644)
152167
if err != nil {
153-
t.Fatalf("Failed to write config file %s: %v", path, err)
168+
s.T().Fatalf("Failed to write config file %s: %v", path, err)
154169
}
155170
return path
156171
}
172+
173+
func TestConfig(t *testing.T) {
174+
suite.Run(t, new(ConfigSuite))
175+
}

0 commit comments

Comments
 (0)