Skip to content

Commit 1844da7

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 1844da7

31 files changed

+456
-281
lines changed

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+
}

pkg/http/http_test.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"time"
2222

2323
"github.com/containers/kubernetes-mcp-server/internal/test"
24-
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
2524
"github.com/coreos/go-oidc/v3/oidc"
2625
"github.com/coreos/go-oidc/v3/oidc/oidctest"
2726
"golang.org/x/sync/errgroup"
@@ -63,7 +62,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
6362
t.Helper()
6463
http.DefaultClient.Timeout = 10 * time.Second
6564
if c.StaticConfig == nil {
66-
c.StaticConfig = &config.StaticConfig{}
65+
c.StaticConfig = config.Default()
6766
}
6867
c.mockServer = test.NewMockServer()
6968
// Fake Kubernetes configuration
@@ -87,10 +86,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
8786
t.Fatalf("Failed to close random port listener: %v", randomPortErr)
8887
}
8988
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
90-
mcpServer, err := mcp.NewServer(mcp.Configuration{
91-
Toolset: toolsets.Toolsets()[0],
92-
StaticConfig: c.StaticConfig,
93-
})
89+
mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig})
9490
if err != nil {
9591
t.Fatalf("Failed to create MCP server: %v", err)
9692
}

0 commit comments

Comments
 (0)