feat(toolsets): add support for multiple toolsets in configuration (#323)

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 <marc@marcnuri.com>
This commit is contained in:
Marc Nuri
2025-09-17 10:53:56 +02:00
committed by GitHub
parent 3fc4fa49bb
commit 48cf204a89
37 changed files with 688 additions and 507 deletions

View File

@@ -20,7 +20,7 @@ type Toolset interface {
// Examples: "core", "metrics", "helm"
GetName() string
GetDescription() string
GetTools(k *internalk8s.Manager) []ServerTool
GetTools(o internalk8s.Openshift) []ServerTool
}
type ToolCallRequest interface {

View File

@@ -20,6 +20,7 @@ type StaticConfig struct {
ReadOnly bool `toml:"read_only,omitempty"`
// When true, disable tools annotated with destructiveHint=true
DisableDestructive bool `toml:"disable_destructive,omitempty"`
Toolsets []string `toml:"toolsets,omitempty"`
EnabledTools []string `toml:"enabled_tools,omitempty"`
DisabledTools []string `toml:"disabled_tools,omitempty"`
@@ -50,22 +51,32 @@ type StaticConfig struct {
ServerURL string `toml:"server_url,omitempty"`
}
func Default() *StaticConfig {
return &StaticConfig{
ListOutput: "table",
Toolsets: []string{"core", "config", "helm"},
}
}
type GroupVersionKind struct {
Group string `toml:"group"`
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
}
// ReadConfig reads the toml file and returns the StaticConfig.
func ReadConfig(configPath string) (*StaticConfig, error) {
// Read reads the toml file and returns the StaticConfig.
func Read(configPath string) (*StaticConfig, error) {
configData, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
return ReadToml(configData)
}
var config *StaticConfig
err = toml.Unmarshal(configData, &config)
if err != nil {
// ReadToml reads the toml data and returns the StaticConfig.
func ReadToml(configData []byte) (*StaticConfig, error) {
config := Default()
if err := toml.Unmarshal(configData, config); err != nil {
return nil, err
}
return config, nil

View File

@@ -1,156 +1,175 @@
package config
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/suite"
)
func TestReadConfigMissingFile(t *testing.T) {
config, err := ReadConfig("non-existent-config.toml")
t.Run("returns error for missing file", func(t *testing.T) {
if err == nil {
t.Fatal("Expected error for missing file, got nil")
}
if config != nil {
t.Fatalf("Expected nil config for missing file, got %v", config)
}
type ConfigSuite struct {
suite.Suite
}
func (s *ConfigSuite) TestReadConfigMissingFile() {
config, err := Read("non-existent-config.toml")
s.Run("returns error for missing file", func() {
s.Require().NotNil(err, "Expected error for missing file, got nil")
s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
})
s.Run("returns nil config for missing file", func() {
s.Nil(config, "Expected nil config for missing file")
})
}
func TestReadConfigInvalid(t *testing.T) {
invalidConfigPath := writeConfig(t, `
[[denied_resources]]
group = "apps"
version = "v1"
kind = "Deployment"
[[denied_resources]]
group = "rbac.authorization.k8s.io"
version = "v1"
kind = "Role
`)
func (s *ConfigSuite) TestReadConfigInvalid() {
invalidConfigPath := s.writeConfig(`
[[denied_resources]]
group = "apps"
version = "v1"
kind = "Deployment"
[[denied_resources]]
group = "rbac.authorization.k8s.io"
version = "v1"
kind = "Role
`)
config, err := ReadConfig(invalidConfigPath)
t.Run("returns error for invalid file", func(t *testing.T) {
if err == nil {
t.Fatal("Expected error for invalid file, got nil")
}
if config != nil {
t.Fatalf("Expected nil config for invalid file, got %v", config)
}
config, err := Read(invalidConfigPath)
s.Run("returns error for invalid file", func() {
s.Require().NotNil(err, "Expected error for invalid file, got nil")
})
t.Run("error message contains toml error with line number", func(t *testing.T) {
s.Run("error message contains toml error with line number", func() {
expectedError := "toml: line 9"
if err != nil && !strings.HasPrefix(err.Error(), expectedError) {
t.Fatalf("Expected error message '%s' to contain line number, got %v", expectedError, err)
s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
})
s.Run("returns nil config for invalid file", func() {
s.Nil(config, "Expected nil config for missing file")
})
}
func (s *ConfigSuite) TestReadConfigValid() {
validConfigPath := s.writeConfig(`
log_level = 1
port = "9999"
sse_base_url = "https://example.com"
kubeconfig = "./path/to/config"
list_output = "yaml"
read_only = true
disable_destructive = true
toolsets = ["core", "config", "helm", "metrics"]
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
denied_resources = [
{group = "apps", version = "v1", kind = "Deployment"},
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
]
`)
config, err := Read(validConfigPath)
s.Require().NotNil(config)
s.Run("reads and unmarshalls file", func() {
s.Nil(err, "Expected nil error for valid file")
s.Require().NotNil(config, "Expected non-nil config for valid file")
})
s.Run("log_level parsed correctly", func() {
s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
})
s.Run("port parsed correctly", func() {
s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", config.Port)
})
s.Run("sse_base_url parsed correctly", func() {
s.Equalf("https://example.com", config.SSEBaseURL, "Expected SSEBaseURL to be https://example.com, got %s", config.SSEBaseURL)
})
s.Run("kubeconfig parsed correctly", func() {
s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", config.KubeConfig)
})
s.Run("list_output parsed correctly", func() {
s.Equalf("yaml", config.ListOutput, "Expected ListOutput to be yaml, got %s", config.ListOutput)
})
s.Run("read_only parsed correctly", func() {
s.Truef(config.ReadOnly, "Expected ReadOnly to be true, got %v", config.ReadOnly)
})
s.Run("disable_destructive parsed correctly", func() {
s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive)
})
s.Run("toolsets", func() {
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
for _, toolset := range []string{"core", "config", "helm", "metrics"} {
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
}
})
s.Run("enabled_tools", func() {
s.Require().Lenf(config.EnabledTools, 8, "Expected 8 enabled tools, got %d", len(config.EnabledTools))
for _, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
s.Containsf(config.EnabledTools, tool, "Expected enabled tools to contain %s", tool)
}
})
s.Run("disabled_tools", func() {
s.Require().Lenf(config.DisabledTools, 5, "Expected 5 disabled tools, got %d", len(config.DisabledTools))
for _, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
s.Containsf(config.DisabledTools, tool, "Expected disabled tools to contain %s", tool)
}
})
s.Run("denied_resources", func() {
s.Require().Lenf(config.DeniedResources, 2, "Expected 2 denied resources, got %d", len(config.DeniedResources))
s.Run("contains apps/v1/Deployment", func() {
s.Contains(config.DeniedResources, GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
"Expected denied resources to contain apps/v1/Deployment")
})
s.Run("contains rbac.authorization.k8s.io/v1/Role", func() {
s.Contains(config.DeniedResources, GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"},
"Expected denied resources to contain rbac.authorization.k8s.io/v1/Role")
})
})
}
func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
validConfigPath := s.writeConfig(`
port = "1337"
`)
config, err := Read(validConfigPath)
s.Require().NotNil(config)
s.Run("reads and unmarshalls file", func() {
s.Nil(err, "Expected nil error for valid file")
s.Require().NotNil(config, "Expected non-nil config for valid file")
})
s.Run("log_level defaulted correctly", func() {
s.Equalf(0, config.LogLevel, "Expected LogLevel to be 0, got %d", config.LogLevel)
})
s.Run("port parsed correctly", func() {
s.Equalf("1337", config.Port, "Expected Port to be 9999, got %s", config.Port)
})
s.Run("list_output defaulted correctly", func() {
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
})
s.Run("toolsets defaulted correctly", func() {
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
for _, toolset := range []string{"core", "config", "helm"} {
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
}
})
}
func TestReadConfigValid(t *testing.T) {
validConfigPath := writeConfig(t, `
log_level = 1
port = "9999"
sse_base_url = "https://example.com"
kubeconfig = "./path/to/config"
list_output = "yaml"
read_only = true
disable_destructive = true
denied_resources = [
{group = "apps", version = "v1", kind = "Deployment"},
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
]
enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
`)
config, err := ReadConfig(validConfigPath)
t.Run("reads and unmarshalls file", func(t *testing.T) {
if err != nil {
t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
}
if config == nil {
t.Fatal("ReadConfig returned a nil config for a valid file")
}
})
t.Run("denied resources are parsed correctly", func(t *testing.T) {
if len(config.DeniedResources) != 2 {
t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
}
if config.DeniedResources[0].Group != "apps" ||
config.DeniedResources[0].Version != "v1" ||
config.DeniedResources[0].Kind != "Deployment" {
t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
}
})
t.Run("log_level parsed correctly", func(t *testing.T) {
if config.LogLevel != 1 {
t.Fatalf("Unexpected log level: %v", config.LogLevel)
}
})
t.Run("port parsed correctly", func(t *testing.T) {
if config.Port != "9999" {
t.Fatalf("Unexpected port value: %v", config.Port)
}
})
t.Run("sse_base_url parsed correctly", func(t *testing.T) {
if config.SSEBaseURL != "https://example.com" {
t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
}
})
t.Run("kubeconfig parsed correctly", func(t *testing.T) {
if config.KubeConfig != "./path/to/config" {
t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
}
})
t.Run("list_output parsed correctly", func(t *testing.T) {
if config.ListOutput != "yaml" {
t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
}
})
t.Run("read_only parsed correctly", func(t *testing.T) {
if !config.ReadOnly {
t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
}
})
t.Run("disable_destructive parsed correctly", func(t *testing.T) {
if !config.DisableDestructive {
t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
}
})
t.Run("enabled_tools parsed correctly", func(t *testing.T) {
if len(config.EnabledTools) != 8 {
t.Fatalf("Unexpected enabled tools: %v", config.EnabledTools)
}
for i, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
if config.EnabledTools[i] != tool {
t.Errorf("Expected enabled tool %d to be %s, got %s", i, tool, config.EnabledTools[i])
}
}
})
t.Run("disabled_tools parsed correctly", func(t *testing.T) {
if len(config.DisabledTools) != 5 {
t.Fatalf("Unexpected disabled tools: %v", config.DisabledTools)
}
for i, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
if config.DisabledTools[i] != tool {
t.Errorf("Expected disabled tool %d to be %s, got %s", i, tool, config.DisabledTools[i])
}
}
})
}
func writeConfig(t *testing.T, content string) string {
t.Helper()
tempDir := t.TempDir()
func (s *ConfigSuite) writeConfig(content string) string {
s.T().Helper()
tempDir := s.T().TempDir()
path := filepath.Join(tempDir, "config.toml")
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to write config file %s: %v", path, err)
s.T().Fatalf("Failed to write config file %s: %v", path, err)
}
return path
}
func TestConfig(t *testing.T) {
suite.Run(t, new(ConfigSuite))
}

View File

@@ -21,7 +21,6 @@ import (
"time"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup"
@@ -63,7 +62,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
t.Helper()
http.DefaultClient.Timeout = 10 * time.Second
if c.StaticConfig == nil {
c.StaticConfig = &config.StaticConfig{}
c.StaticConfig = config.Default()
}
c.mockServer = test.NewMockServer()
// Fake Kubernetes configuration
@@ -87,10 +86,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
t.Fatalf("Failed to close random port listener: %v", randomPortErr)
}
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
mcpServer, err := mcp.NewServer(mcp.Configuration{
Toolset: toolsets.Toolsets()[0],
StaticConfig: c.StaticConfig,
})
mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig})
if err != nil {
t.Fatalf("Failed to create MCP server: %v", err)
}

View File

@@ -13,7 +13,6 @@ import (
"strconv"
"strings"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"
@@ -27,6 +26,7 @@ import (
internalhttp "github.com/containers/kubernetes-mcp-server/pkg/http"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/containers/kubernetes-mcp-server/pkg/version"
)
@@ -58,7 +58,7 @@ type MCPServerOptions struct {
HttpPort int
SSEBaseUrl string
Kubeconfig string
Toolset string
Toolsets []string
ListOutput string
ReadOnly bool
DisableDestructive bool
@@ -78,9 +78,7 @@ type MCPServerOptions struct {
func NewMCPServerOptions(streams genericiooptions.IOStreams) *MCPServerOptions {
return &MCPServerOptions{
IOStreams: streams,
Toolset: "full",
ListOutput: "table",
StaticConfig: &config.StaticConfig{},
StaticConfig: config.Default(),
}
}
@@ -116,8 +114,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)")
cmd.Flags().StringVar(&o.SSEBaseUrl, "sse-base-url", o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(toolsets.ToolsetNames(), ", ")+")")
cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.")
cmd.Flags().StringSliceVar(&o.Toolsets, "toolsets", o.Toolsets, "Comma-separated list of MCP toolsets to use (available toolsets: "+strings.Join(toolsets.ToolsetNames(), ", ")+"). Defaults to "+strings.Join(o.StaticConfig.Toolsets, ", ")+".")
cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to "+o.StaticConfig.ListOutput+".")
cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed")
cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled")
cmd.Flags().BoolVar(&o.RequireOAuth, "require-oauth", o.RequireOAuth, "If true, requires OAuth authorization as defined in the Model Context Protocol (MCP) specification. This flag is ignored if transport type is stdio")
@@ -138,7 +136,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
func (m *MCPServerOptions) Complete(cmd *cobra.Command) error {
if m.ConfigPath != "" {
cnf, err := config.ReadConfig(m.ConfigPath)
cnf, err := config.Read(m.ConfigPath)
if err != nil {
return err
}
@@ -174,7 +172,7 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("kubeconfig").Changed {
m.StaticConfig.KubeConfig = m.Kubeconfig
}
if cmd.Flag("list-output").Changed || m.StaticConfig.ListOutput == "" {
if cmd.Flag("list-output").Changed {
m.StaticConfig.ListOutput = m.ListOutput
}
if cmd.Flag("read-only").Changed {
@@ -183,6 +181,9 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag("disable-destructive").Changed {
m.StaticConfig.DisableDestructive = m.DisableDestructive
}
if cmd.Flag("toolsets").Changed {
m.StaticConfig.Toolsets = m.Toolsets
}
if cmd.Flag("require-oauth").Changed {
m.StaticConfig.RequireOAuth = m.RequireOAuth
}
@@ -219,6 +220,12 @@ func (m *MCPServerOptions) Validate() error {
if m.Port != "" && (m.SSEPort > 0 || m.HttpPort > 0) {
return fmt.Errorf("--port is mutually exclusive with deprecated --http-port and --sse-port flags")
}
if output.FromString(m.StaticConfig.ListOutput) == nil {
return fmt.Errorf("invalid output name: %s, valid names are: %s", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
}
if err := toolsets.Validate(m.StaticConfig.Toolsets); err != nil {
return err
}
if !m.StaticConfig.RequireOAuth && (m.StaticConfig.ValidateToken || m.StaticConfig.OAuthAudience != "" || m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.CertificateAuthority != "") {
return fmt.Errorf("validate-token, oauth-audience, authorization-url, server-url and certificate-authority are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false")
}
@@ -238,18 +245,10 @@ func (m *MCPServerOptions) Validate() error {
}
func (m *MCPServerOptions) Run() error {
toolset := toolsets.ToolsetFromString(m.Toolset)
if toolset == nil {
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(toolsets.ToolsetNames(), ", "))
}
listOutput := output.FromString(m.StaticConfig.ListOutput)
if listOutput == nil {
return fmt.Errorf("invalid output name: %s, valid names are: %s", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
}
klog.V(1).Info("Starting kubernetes-mcp-server")
klog.V(1).Infof(" - Config: %s", m.ConfigPath)
klog.V(1).Infof(" - Toolset: %s", toolset.GetName())
klog.V(1).Infof(" - ListOutput: %s", listOutput.GetName())
klog.V(1).Infof(" - Toolsets: %s", strings.Join(m.StaticConfig.Toolsets, ", "))
klog.V(1).Infof(" - ListOutput: %s", m.StaticConfig.ListOutput)
klog.V(1).Infof(" - Read-only mode: %t", m.StaticConfig.ReadOnly)
klog.V(1).Infof(" - Disable destructive tools: %t", m.StaticConfig.DisableDestructive)
@@ -291,11 +290,7 @@ func (m *MCPServerOptions) Run() error {
oidcProvider = provider
}
mcpServer, err := mcp.NewServer(mcp.Configuration{
Toolset: toolset,
ListOutput: listOutput,
StaticConfig: m.StaticConfig,
})
mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: m.StaticConfig})
if err != nil {
return fmt.Errorf("failed to initialize MCP server: %w", err)
}

View File

@@ -129,13 +129,13 @@ func TestConfig(t *testing.T) {
})
}
func TestToolset(t *testing.T) {
func TestToolsets(t *testing.T) {
t.Run("available", func(t *testing.T) {
ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--help"})
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
if !strings.Contains(o, "MCP toolset to use (one of: full) ") {
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
}
})
@@ -143,16 +143,16 @@ func TestToolset(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolset: full") {
t.Fatalf("Expected toolset 'full', got %s %v", out, err)
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
}
})
t.Run("set with --toolset", func(t *testing.T) {
t.Run("set with --toolsets", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--toolset", "full"}) // TODO: change by some non-default toolset
rootCmd.SetArgs([]string{"--version", "--log-level=1", "--toolsets", "helm,config"})
_ = rootCmd.Execute()
expected := `(?m)\" - Toolset\: full\"`
expected := `(?m)\" - Toolsets\: helm, config\"`
if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
t.Fatalf("Expected toolset to be %s, got %s %v", expected, out.String(), err)
}

View File

@@ -53,11 +53,12 @@ type Manager struct {
CloseWatchKubeConfig CloseWatchKubeConfig
}
var _ helm.Kubernetes = (*Manager)(nil)
var _ Openshift = (*Manager)(nil)
var Scheme = scheme.Scheme
var ParameterCodec = runtime.NewParameterCodec(Scheme)
var _ helm.Kubernetes = &Manager{}
func NewManager(config *config.StaticConfig) (*Manager, error) {
k8s := &Manager{
staticConfig: config,

View File

@@ -6,6 +6,10 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)
type Openshift interface {
IsOpenShift(context.Context) bool
}
func (m *Manager) IsOpenShift(_ context.Context) bool {
// This method should be fast and not block (it's called at startup)
_, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{

View File

@@ -42,10 +42,8 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
)
// envTest has an expensive setup, so we only want to do it once per entire test run.
@@ -106,7 +104,7 @@ func TestMain(m *testing.M) {
}
type mcpContext struct {
toolset api.Toolset
toolsets []string
listOutput output.Output
logLevel int
@@ -129,17 +127,17 @@ func (c *mcpContext) beforeEach(t *testing.T) {
c.ctx, c.cancel = context.WithCancel(t.Context())
c.tempDir = t.TempDir()
c.withKubeConfig(nil)
if c.toolset == nil {
c.toolset = &full.Full{}
}
if c.listOutput == nil {
c.listOutput = output.Yaml
}
if c.staticConfig == nil {
c.staticConfig = &config.StaticConfig{
ReadOnly: false,
DisableDestructive: false,
}
c.staticConfig = config.Default()
// Default to use YAML output for lists (previously the default)
c.staticConfig.ListOutput = "yaml"
}
if c.toolsets != nil {
c.staticConfig.Toolsets = c.toolsets
}
if c.listOutput != nil {
c.staticConfig.ListOutput = c.listOutput.GetName()
}
if c.before != nil {
c.before(c)
@@ -151,11 +149,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
_ = flags.Set("v", strconv.Itoa(c.logLevel))
klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(c.logLevel), textlogger.Output(&c.logBuffer))))
// MCP Server
if c.mcpServer, err = NewServer(Configuration{
Toolset: c.toolset,
ListOutput: c.listOutput,
StaticConfig: c.staticConfig,
}); err != nil {
if c.mcpServer, err = NewServer(Configuration{StaticConfig: c.staticConfig}); err != nil {
t.Fatal(err)
return
}
@@ -191,7 +185,7 @@ func (c *mcpContext) afterEach() {
}
func testCase(t *testing.T, test func(c *mcpContext)) {
testCaseWithContext(t, &mcpContext{toolset: &full.Full{}}, test)
testCaseWithContext(t, &mcpContext{}, test)
}
func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) {

View File

@@ -1,11 +1,14 @@
package mcp
import (
"github.com/containers/kubernetes-mcp-server/pkg/config"
"testing"
"github.com/mark3labs/mcp-go/mcp"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"testing"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestEventsList(t *testing.T) {
@@ -96,7 +99,9 @@ func TestEventsList(t *testing.T) {
}
func TestEventsListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Event"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Event" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
eventList, _ := c.callTool("events_list", map[string]interface{}{})

View File

@@ -8,13 +8,15 @@ import (
"strings"
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/mark3labs/mcp-go/mcp"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/yaml"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestHelmInstall(t *testing.T) {
@@ -60,7 +62,9 @@ func TestHelmInstall(t *testing.T) {
}
func TestHelmInstallDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Secret" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
_, file, _, _ := runtime.Caller(0)
@@ -226,7 +230,9 @@ func TestHelmUninstall(t *testing.T) {
}
func TestHelmUninstallDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Secret" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()

View File

@@ -41,7 +41,7 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S
Context: ctx,
Kubernetes: k,
ToolCallRequest: request,
ListOutput: s.configuration.ListOutput,
ListOutput: s.configuration.ListOutput(),
})
if err != nil {
return nil, err

View File

@@ -7,16 +7,17 @@ import (
"net/http"
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
authenticationapiv1 "k8s.io/api/authentication/v1"
"k8s.io/klog/v2"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
"github.com/containers/kubernetes-mcp-server/pkg/version"
)
@@ -25,10 +26,25 @@ type ContextKey string
const TokenScopesContextKey = ContextKey("TokenScopesContextKey")
type Configuration struct {
Toolset api.Toolset
ListOutput output.Output
*config.StaticConfig
listOutput output.Output
toolsets []api.Toolset
}
StaticConfig *config.StaticConfig
func (c *Configuration) Toolsets() []api.Toolset {
if c.toolsets == nil {
for _, toolset := range c.StaticConfig.Toolsets {
c.toolsets = append(c.toolsets, toolsets.ToolsetFromString(toolset))
}
}
return c.toolsets
}
func (c *Configuration) ListOutput() output.Output {
if c.listOutput == nil {
c.listOutput = output.FromString(c.StaticConfig.ListOutput)
}
return c.listOutput
}
func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
@@ -90,12 +106,14 @@ func (s *Server) reloadKubernetesClient() error {
}
s.k = k
applicableTools := make([]api.ServerTool, 0)
for _, tool := range s.configuration.Toolset.GetTools(s.k) {
if !s.configuration.isToolApplicable(tool) {
continue
for _, toolset := range s.configuration.Toolsets() {
for _, tool := range toolset.GetTools(s.k) {
if !s.configuration.isToolApplicable(tool) {
continue
}
applicableTools = append(applicableTools, tool)
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
applicableTools = append(applicableTools, tool)
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
}
m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"k8s.io/utils/ptr"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)
@@ -74,11 +75,10 @@ func TestDisableDestructive(t *testing.T) {
}
func TestEnabledTools(t *testing.T) {
testCaseWithContext(t, &mcpContext{
staticConfig: &config.StaticConfig{
EnabledTools: []string{"namespaces_list", "events_list"},
},
}, func(c *mcpContext) {
enabledToolsServer := test.Must(config.ReadToml([]byte(`
enabled_tools = [ "namespaces_list", "events_list" ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: enabledToolsServer}, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {

View File

@@ -1,3 +1,5 @@
package mcp
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"

View File

@@ -5,14 +5,16 @@ import (
"slices"
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func TestNamespacesList(t *testing.T) {
@@ -51,7 +53,9 @@ func TestNamespacesList(t *testing.T) {
}
func TestNamespacesListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Namespace"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Namespace" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
namespacesList, _ := c.callTool("namespaces_list", map[string]interface{}{})
@@ -156,7 +160,9 @@ func TestProjectsListInOpenShift(t *testing.T) {
}
func TestProjectsListInOpenShiftDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "project.openshift.io", Version: "v1"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { group = "project.openshift.io", version = "v1" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
c.withEnvTest()
projectsList, _ := c.callTool("projects_list", map[string]interface{}{})

View File

@@ -7,11 +7,12 @@ import (
"strings"
"testing"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/mark3labs/mcp-go/mcp"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)
func TestPodsExec(t *testing.T) {
@@ -104,7 +105,9 @@ func TestPodsExec(t *testing.T) {
}
func TestPodsExecDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsRun, _ := c.callTool("pods_exec", map[string]interface{}{

View File

@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
@@ -179,7 +180,9 @@ func TestPodsListInNamespace(t *testing.T) {
}
func TestPodsListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsList, _ := c.callTool("pods_list", map[string]interface{}{})
@@ -414,7 +417,9 @@ func TestPodsGet(t *testing.T) {
}
func TestPodsGetDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsGet, _ := c.callTool("pods_get", map[string]interface{}{"name": "a-pod-in-default"})
@@ -564,7 +569,9 @@ func TestPodsDelete(t *testing.T) {
}
func TestPodsDeleteDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsDelete, _ := c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-in-default"})
@@ -753,7 +760,9 @@ func TestPodsLog(t *testing.T) {
}
func TestPodsLogDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsLog, _ := c.callTool("pods_log", map[string]interface{}{"name": "a-pod-in-default"})
@@ -922,7 +931,9 @@ func TestPodsRun(t *testing.T) {
}
func TestPodsRunDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { version = "v1", kind = "Pod" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsRun, _ := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})

View File

@@ -210,7 +210,9 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
}
func TestPodsTopDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "metrics.k8s.io", Version: "v1beta1"}}}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
mockServer := test.NewMockServer()
defer mockServer.Close()

View File

@@ -14,6 +14,7 @@ import (
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
@@ -152,12 +153,12 @@ func TestResourcesList(t *testing.T) {
}
func TestResourcesListDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{
DeniedResources: []config.GroupVersionKind{
{Version: "v1", Kind: "Secret"},
{Group: "rbac.authorization.k8s.io", Version: "v1"},
},
}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [
{ version = "v1", kind = "Secret" },
{ group = "rbac.authorization.k8s.io", version = "v1" }
]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
deniedByKind, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"})
@@ -357,12 +358,12 @@ func TestResourcesGet(t *testing.T) {
}
func TestResourcesGetDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{
DeniedResources: []config.GroupVersionKind{
{Version: "v1", Kind: "Secret"},
{Group: "rbac.authorization.k8s.io", Version: "v1"},
},
}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [
{ version = "v1", kind = "Secret" },
{ group = "rbac.authorization.k8s.io", version = "v1" }
]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
@@ -583,12 +584,12 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
}
func TestResourcesCreateOrUpdateDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{
DeniedResources: []config.GroupVersionKind{
{Version: "v1", Kind: "Secret"},
{Group: "rbac.authorization.k8s.io", Version: "v1"},
},
}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [
{ version = "v1", kind = "Secret" },
{ group = "rbac.authorization.k8s.io", version = "v1" }
]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
secretYaml := "apiVersion: v1\nkind: Secret\nmetadata:\n name: a-denied-secret\n namespace: default\n"
@@ -745,12 +746,12 @@ func TestResourcesDelete(t *testing.T) {
}
func TestResourcesDeleteDenied(t *testing.T) {
deniedResourcesServer := &config.StaticConfig{
DeniedResources: []config.GroupVersionKind{
{Version: "v1", Kind: "Secret"},
{Group: "rbac.authorization.k8s.io", Version: "v1"},
},
}
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
denied_resources = [
{ version = "v1", kind = "Secret" },
{ group = "rbac.authorization.k8s.io", version = "v1" }
]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()

View File

@@ -9,12 +9,11 @@ import (
"strings"
"testing"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets/full"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)
func TestFullToolsetTools(t *testing.T) {
func TestDefaultToolsetTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
@@ -35,7 +34,7 @@ func TestFullToolsetTools(t *testing.T) {
"resources_create_or_update",
"resources_delete",
}
mcpCtx := &mcpContext{toolset: &full.Full{}}
mcpCtx := &mcpContext{}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
@@ -72,11 +71,10 @@ func TestFullToolsetTools(t *testing.T) {
})
}
func TestFullToolsetToolsInOpenShift(t *testing.T) {
func TestDefaultToolsetToolsInOpenShift(t *testing.T) {
mcpCtx := &mcpContext{
toolset: &full.Full{},
before: inOpenShift,
after: inOpenShiftClear,
before: inOpenShift,
after: inOpenShiftClear,
}
testCaseWithContext(t, mcpCtx, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})

View File

@@ -1,4 +1,4 @@
package full
package config
import (
"fmt"

View File

@@ -0,0 +1,31 @@
package config
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
)
type Toolset struct{}
var _ api.Toolset = (*Toolset)(nil)
func (t *Toolset) GetName() string {
return "config"
}
func (t *Toolset) GetDescription() string {
return "View and manage the current local Kubernetes configuration (kubeconfig)"
}
func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
return slices.Concat(
initConfiguration(),
)
}
func init() {
toolsets.Register(&Toolset{})
}

View File

@@ -1,4 +1,4 @@
package full
package core
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package full
package core
import (
"context"
@@ -12,7 +12,7 @@ import (
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)
func initNamespaces(k *internalk8s.Manager) []api.ServerTool {
func initNamespaces(o internalk8s.Openshift) []api.ServerTool {
ret := make([]api.ServerTool, 0)
ret = append(ret, api.ServerTool{
Tool: api.Tool{
@@ -30,7 +30,7 @@ func initNamespaces(k *internalk8s.Manager) []api.ServerTool {
},
}, Handler: namespacesList,
})
if k.IsOpenShift(context.Background()) {
if o.IsOpenShift(context.Background()) {
ret = append(ret, api.ServerTool{
Tool: api.Tool{
Name: "projects_list",

View File

@@ -1,4 +1,4 @@
package full
package core
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package full
package core
import (
"context"
@@ -15,9 +15,9 @@ import (
"github.com/containers/kubernetes-mcp-server/pkg/output"
)
func initResources(k *internalk8s.Manager) []api.ServerTool {
func initResources(o internalk8s.Openshift) []api.ServerTool {
commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
if k.IsOpenShift(context.Background()) {
if o.IsOpenShift(context.Background()) {
commonApiVersion += ", route.openshift.io/v1 Route"
}
commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)

View File

@@ -0,0 +1,34 @@
package core
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
)
type Toolset struct{}
var _ api.Toolset = (*Toolset)(nil)
func (t *Toolset) GetName() string {
return "core"
}
func (t *Toolset) GetDescription() string {
return "Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.)"
}
func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
return slices.Concat(
initEvents(),
initNamespaces(o),
initPods(),
initResources(o),
)
}
func init() {
toolsets.Register(&Toolset{})
}

View File

@@ -1,36 +0,0 @@
package full
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
)
type Full struct{}
var _ api.Toolset = (*Full)(nil)
func (p *Full) GetName() string {
return "full"
}
func (p *Full) GetDescription() string {
return "Complete toolset with all tools and extended outputs"
}
func (p *Full) GetTools(k *internalk8s.Manager) []api.ServerTool {
return slices.Concat(
initConfiguration(),
initEvents(),
initNamespaces(k),
initPods(),
initResources(k),
initHelm(),
)
}
func init() {
toolsets.Register(&Full{})
}

View File

@@ -1,4 +1,4 @@
package full
package helm
import (
"fmt"

View File

@@ -0,0 +1,31 @@
package helm
import (
"slices"
"github.com/containers/kubernetes-mcp-server/pkg/api"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
)
type Toolset struct{}
var _ api.Toolset = (*Toolset)(nil)
func (t *Toolset) GetName() string {
return "helm"
}
func (t *Toolset) GetDescription() string {
return "Tools for managing Helm charts and releases"
}
func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool {
return slices.Concat(
initHelm(),
)
}
func init() {
toolsets.Register(&Toolset{})
}

View File

@@ -1,7 +1,9 @@
package toolsets
import (
"fmt"
"slices"
"strings"
"github.com/containers/kubernetes-mcp-server/pkg/api"
)
@@ -32,9 +34,18 @@ func ToolsetNames() []string {
func ToolsetFromString(name string) api.Toolset {
for _, toolset := range Toolsets() {
if toolset.GetName() == name {
if toolset.GetName() == strings.TrimSpace(name) {
return toolset
}
}
return nil
}
func Validate(toolsets []string) error {
for _, toolset := range toolsets {
if ToolsetFromString(toolset) == nil {
return fmt.Errorf("invalid toolset name: %s, valid names are: %s", toolset, strings.Join(ToolsetNames(), ", "))
}
}
return nil
}

View File

@@ -25,7 +25,7 @@ func (t *TestToolset) GetName() string { return t.name }
func (t *TestToolset) GetDescription() string { return t.description }
func (t *TestToolset) GetTools(k *kubernetes.Manager) []api.ServerTool { return nil }
func (t *TestToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool { return nil }
var _ api.Toolset = (*TestToolset)(nil)
@@ -53,6 +53,35 @@ func (s *ToolsetsSuite) TestToolsetFromString() {
s.NotNil(res, "Expected to find the registered toolset")
s.Equal("existent", res.GetName(), "Expected to find the registered toolset by name")
})
s.Run("Returns the correct toolset if found after trimming spaces", func() {
Register(&TestToolset{name: "no-spaces"})
res := ToolsetFromString(" no-spaces ")
s.NotNil(res, "Expected to find the registered toolset")
s.Equal("no-spaces", res.GetName(), "Expected to find the registered toolset by name")
})
}
func (s *ToolsetsSuite) TestValidate() {
s.Run("Returns nil for empty toolset list", func() {
s.Nil(Validate([]string{}), "Expected nil for empty toolset list")
})
s.Run("Returns error for invalid toolset name", func() {
err := Validate([]string{"invalid"})
s.NotNil(err, "Expected error for invalid toolset name")
s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
})
s.Run("Returns nil for valid toolset names", func() {
Register(&TestToolset{name: "valid-1"})
Register(&TestToolset{name: "valid-2"})
err := Validate([]string{"valid-1", "valid-2"})
s.Nil(err, "Expected nil for valid toolset names")
})
s.Run("Returns error if any toolset name is invalid", func() {
Register(&TestToolset{name: "valid"})
err := Validate([]string{"valid", "invalid"})
s.NotNil(err, "Expected error if any toolset name is invalid")
s.Contains(err.Error(), "invalid toolset name: invalid", "Expected error message to contain invalid toolset name")
})
}
func TestToolsets(t *testing.T) {