mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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{}{})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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{}{})
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
31
pkg/toolsets/config/toolset.go
Normal file
31
pkg/toolsets/config/toolset.go
Normal 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{})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -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",
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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)
|
||||
34
pkg/toolsets/core/toolset.go
Normal file
34
pkg/toolsets/core/toolset.go
Normal 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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package full
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
31
pkg/toolsets/helm/toolset.go
Normal file
31
pkg/toolsets/helm/toolset.go
Normal 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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user