From 754da19d81438c435ca5434573b65e4c0fb1e1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 19 Jun 2025 14:41:47 +0300 Subject: [PATCH] feat(config): introduce toml configuration file with a set of deny list --- go.mod | 2 +- pkg/config/config.go | 31 +++++++++++++++++ pkg/config/config_test.go | 48 +++++++++++++++++++++++++++ pkg/kubernetes-mcp-server/cmd/root.go | 16 ++++++++- pkg/kubernetes/kubernetes.go | 18 +++++++--- pkg/kubernetes/resources.go | 44 ++++++++++++++++++++++++ pkg/mcp/common_test.go | 23 ++++++++++++- pkg/mcp/mcp.go | 8 +++-- pkg/mcp/mcp_test.go | 8 +++-- pkg/mcp/resources_test.go | 20 +++++++++++ pkg/output/output.go | 2 +- 11 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go diff --git a/go.mod b/go.mod index a028d0c..e2730ad 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/manusa/kubernetes-mcp-server go 1.24.1 require ( + github.com/BurntSushi/toml v1.5.0 github.com/fsnotify/fsnotify v1.9.0 github.com/mark3labs/mcp-go v0.32.0 github.com/pkg/errors v0.9.1 @@ -28,7 +29,6 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/BurntSushi/toml v1.5.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..f6471ea --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + + "github.com/BurntSushi/toml" +) + +type StaticConfig struct { + DeniedResources []GroupVersionKind `toml:"denied_resources"` +} + +type GroupVersionKind struct { + Group string `toml:"group"` + Version string `toml:"version"` + Kind string `toml:"kind,omitempty"` +} + +func ReadConfig(configPath string) (*StaticConfig, error) { + configData, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config *StaticConfig + err = toml.Unmarshal(configData, &config) + if err != nil { + return nil, err + } + return config, nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..a36fc83 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadConfig(t *testing.T) { + tempDir := t.TempDir() + + t.Run("ValidConfigFileWithDeniedResources", func(t *testing.T) { + validConfigContent := ` +[[denied_resources]] +group = "apps" +version = "v1" +kind = "Deployment" + +[[denied_resources]] +group = "rbac.authorization.k8s.io" +version = "v1" +` + validConfigPath := filepath.Join(tempDir, "valid_denied_config.toml") + err := os.WriteFile(validConfigPath, []byte(validConfigContent), 0644) + if err != nil { + t.Fatalf("Failed to write valid config file: %v", err) + } + + config, err := ReadConfig(validConfigPath) + 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") + } + + 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]) + } + }) +} diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 8bda942..1becd2e 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -16,6 +16,7 @@ import ( "k8s.io/kubectl/pkg/util/i18n" "k8s.io/kubectl/pkg/util/templates" + "github.com/manusa/kubernetes-mcp-server/pkg/config" "github.com/manusa/kubernetes-mcp-server/pkg/mcp" "github.com/manusa/kubernetes-mcp-server/pkg/output" "github.com/manusa/kubernetes-mcp-server/pkg/version" @@ -53,6 +54,9 @@ type MCPServerOptions struct { ReadOnly bool DisableDestructive bool + ConfigPath string + StaticConfig *config.StaticConfig + genericiooptions.IOStreams } @@ -86,6 +90,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { }, } + cmd.Flags().StringVar(&o.ConfigPath, "config", o.ConfigPath, "Path of the config file. Each profile has its set of defaults.") cmd.Flags().BoolVar(&o.Version, "version", o.Version, "Print version information and quit") cmd.Flags().IntVar(&o.LogLevel, "log-level", o.LogLevel, "Set the log level (from 0 to 9)") cmd.Flags().IntVar(&o.SSEPort, "sse-port", o.SSEPort, "Start a SSE server on the specified port") @@ -103,6 +108,14 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { func (m *MCPServerOptions) Complete() error { m.initializeLogging() + if m.ConfigPath != "" { + cnf, err := config.ReadConfig(m.ConfigPath) + if err != nil { + return err + } + m.StaticConfig = cnf + } + return nil } @@ -141,12 +154,13 @@ func (m *MCPServerOptions) Run() error { fmt.Fprintf(m.Out, "%s\n", version.Version) return nil } - mcpServer, err := mcp.NewSever(mcp.Configuration{ + mcpServer, err := mcp.NewServer(mcp.Configuration{ Profile: profile, ListOutput: listOutput, ReadOnly: m.ReadOnly, DisableDestructive: m.DisableDestructive, Kubeconfig: m.Kubeconfig, + StaticConfig: m.StaticConfig, }) if err != nil { return fmt.Errorf("Failed to initialize MCP server: %w\n", err) diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 742ede5..e44f92f 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -2,8 +2,10 @@ package kubernetes import ( "context" + "strings" + "github.com/fsnotify/fsnotify" - "github.com/manusa/kubernetes-mcp-server/pkg/helm" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" @@ -11,13 +13,16 @@ import ( "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" - "strings" + + "github.com/manusa/kubernetes-mcp-server/pkg/config" + "github.com/manusa/kubernetes-mcp-server/pkg/helm" + + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" ) const ( @@ -42,11 +47,14 @@ type Manager struct { discoveryClient discovery.CachedDiscoveryInterface deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper dynamicClient *dynamic.DynamicClient + + StaticConfig *config.StaticConfig } -func NewManager(kubeconfig string) (*Manager, error) { +func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) { k8s := &Manager{ - Kubeconfig: kubeconfig, + Kubeconfig: kubeconfig, + StaticConfig: config, } if err := resolveKubernetesConfigurations(k8s); err != nil { return nil, err diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index ba2efbd..efd08f7 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -33,6 +33,11 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion if err != nil { return nil, err } + + if !k.isAllowed(gvk) { + return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) + } + // Check if operation is allowed for all namespaces (applicable for namespaced resources) isNamespaced, _ := k.isNamespaced(gvk) if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" { @@ -49,6 +54,11 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK if err != nil { return nil, err } + + if !k.isAllowed(gvk) { + return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) + } + // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) @@ -75,6 +85,11 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi if err != nil { return err } + + if !k.isAllowed(gvk) { + return fmt.Errorf("resource not allowed: %s", gvk.String()) + } + // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) @@ -136,6 +151,11 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u if rErr != nil { return nil, rErr } + + if !k.isAllowed(&gvk) { + return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) + } + namespace := obj.GetNamespace() // If it's a namespaced resource and namespace wasn't provided, try to use the default configured one if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced { @@ -163,6 +183,30 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer return &m.Resource, nil } +// isAllowed checks the resource is in denied list or not. +// If it is in denied list, this function returns false. +func (k *Kubernetes) isAllowed(gvk *schema.GroupVersionKind) bool { + if k.manager.StaticConfig == nil { + return true + } + + for _, val := range k.manager.StaticConfig.DeniedResources { + // If kind is empty, that means Group/Version pair is denied entirely + if val.Kind == "" { + if gvk.Group == val.Group && gvk.Version == val.Version { + return false + } + } + if gvk.Group == val.Group && + gvk.Version == val.Version && + gvk.Kind == val.Kind { + return false + } + } + + return true +} + func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) if err != nil { diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index fff39f2..b4b0521 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/manusa/kubernetes-mcp-server/pkg/config" "github.com/manusa/kubernetes-mcp-server/pkg/output" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" @@ -100,6 +101,7 @@ type mcpContext struct { listOutput output.Output readOnly bool disableDestructive bool + staticConfig *config.StaticConfig clientOptions []transport.ClientOption before func(*mcpContext) after func(*mcpContext) @@ -125,11 +127,26 @@ func (c *mcpContext) beforeEach(t *testing.T) { if c.before != nil { c.before(c) } - if c.mcpServer, err = NewSever(Configuration{ + if c.staticConfig == nil { + c.staticConfig = &config.StaticConfig{ + DeniedResources: []config.GroupVersionKind{ + { + Version: "v1", + Kind: "Secret", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + }, + }, + } + } + if c.mcpServer, err = NewServer(Configuration{ Profile: c.profile, ListOutput: c.listOutput, ReadOnly: c.readOnly, DisableDestructive: c.disableDestructive, + StaticConfig: c.staticConfig, }); err != nil { t.Fatal(err) return @@ -205,6 +222,10 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config { return fakeConfig } +func (c *mcpContext) withStaticConfig(config *config.StaticConfig) { + c.staticConfig = config +} + // withEnvTest sets up the environment for kubeconfig to be used with envTest func (c *mcpContext) withEnvTest() { c.withKubeConfig(envTestRestConfig) diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index c455a63..8d0d950 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "github.com/manusa/kubernetes-mcp-server/pkg/config" "net/http" "github.com/mark3labs/mcp-go/mcp" @@ -21,6 +22,8 @@ type Configuration struct { // When true, disable tools annotated with destructiveHint=true DisableDestructive bool Kubeconfig string + + StaticConfig *config.StaticConfig } type Server struct { @@ -29,7 +32,7 @@ type Server struct { k *kubernetes.Manager } -func NewSever(configuration Configuration) (*Server, error) { +func NewServer(configuration Configuration) (*Server, error) { s := &Server{ configuration: &configuration, server: server.NewMCPServer( @@ -45,11 +48,12 @@ func NewSever(configuration Configuration) (*Server, error) { return nil, err } s.k.WatchKubeConfig(s.reloadKubernetesClient) + return s, nil } func (s *Server) reloadKubernetesClient() error { - k, err := kubernetes.NewManager(s.configuration.Kubeconfig) + k, err := kubernetes.NewManager(s.configuration.Kubeconfig, s.configuration.StaticConfig) if err != nil { return err } diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index b4e4b41..4b40a15 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -2,14 +2,17 @@ package mcp import ( "context" - "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/mcp" "net/http" "os" "path/filepath" "runtime" "testing" "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/manusa/kubernetes-mcp-server/pkg/config" ) func TestWatchKubeConfig(t *testing.T) { @@ -96,6 +99,7 @@ func TestSseHeaders(t *testing.T) { defer mockServer.Close() before := func(c *mcpContext) { c.withKubeConfig(mockServer.config) + c.withStaticConfig(&config.StaticConfig{}) c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"})) } pathHeaders := make(map[string]http.Header, 0) diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index 0e09fca..57bab64 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -54,6 +54,26 @@ func TestResourcesList(t *testing.T) { t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) } }) + t.Run("resources_list with a resource in denied list as kind", func(t *testing.T) { + toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"}) + if !toolResult.IsError { + t.Fatalf("call tool should fail") + } + //failed to list resources: resource not allowed: /v1, Kind=Secret + if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: resource not allowed: /v1, Kind=Secret` { + t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + } + }) + t.Run("resources_list with a resource in denied list as group", func(t *testing.T) { + toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"}) + if !toolResult.IsError { + t.Fatalf("call tool should fail") + } + //failed to list resources: resource not allowed: /v1, Kind=Secret + if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role` { + t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + } + }) namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) t.Run("resources_list returns namespaces", func(t *testing.T) { if err != nil { diff --git a/pkg/output/output.go b/pkg/output/output.go index d61acc9..c558ae9 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -2,7 +2,7 @@ package output import ( "bytes" - + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime"