Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -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 changes: 48 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
})
}
16 changes: 15 additions & 1 deletion pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -53,6 +54,9 @@ type MCPServerOptions struct {
ReadOnly bool
DisableDestructive bool

ConfigPath string
StaticConfig *config.StaticConfig

genericiooptions.IOStreams
}

Expand Down Expand Up @@ -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")
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions pkg/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,27 @@ 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"
"k8s.io/client-go/discovery"
"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 (
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions pkg/kubernetes/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 22 additions & 1 deletion pkg/mcp/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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",
},
},
}
}
Comment on lines +130 to +143
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, this PR adds predefined set of resources to not allow any operations in MCP Server.

I wouldn't set any opinionated defaults (at least for the default upstream full profile)

Maybe we want to discuss if we want to provide an upstream safe profile or maybe even a mode of operation that users could activate and added the opinionated denied resources.

Everything else looks good :)

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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
"net/http"

"github.com/mark3labs/mcp-go/mcp"
Expand All @@ -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 {
Expand All @@ -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(
Expand All @@ -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
}
Expand Down
Loading
Loading