mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
feat(config): introduce toml configuration file with a set of deny list
This commit is contained in:
2
go.mod
2
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
|
||||
|
||||
31
pkg/config/config.go
Normal file
31
pkg/config/config.go
Normal 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
48
pkg/config/config_test.go
Normal 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user