feat(config): introduce toml configuration file with a set of deny list

This commit is contained in:
Arda Güçlü
2025-06-19 14:41:47 +03:00
committed by GitHub
parent 25608daf4a
commit 754da19d81
11 changed files with 207 additions and 13 deletions

2
go.mod
View File

@@ -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

31
pkg/config/config.go Normal file
View File

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

48
pkg/config/config_test.go Normal file
View File

@@ -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])
}
})
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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"