Skip to content

Commit 754da19

Browse files
authored
feat(config): introduce toml configuration file with a set of deny list
1 parent 25608da commit 754da19

File tree

11 files changed

+207
-13
lines changed

11 files changed

+207
-13
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/manusa/kubernetes-mcp-server
33
go 1.24.1
44

55
require (
6+
github.com/BurntSushi/toml v1.5.0
67
github.com/fsnotify/fsnotify v1.9.0
78
github.com/mark3labs/mcp-go v0.32.0
89
github.com/pkg/errors v0.9.1
@@ -28,7 +29,6 @@ require (
2829
require (
2930
dario.cat/mergo v1.0.1 // indirect
3031
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
31-
github.com/BurntSushi/toml v1.5.0 // indirect
3232
github.com/MakeNowJust/heredoc v1.0.0 // indirect
3333
github.com/Masterminds/goutils v1.1.1 // indirect
3434
github.com/Masterminds/semver/v3 v3.3.0 // indirect

pkg/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package config
2+
3+
import (
4+
"os"
5+
6+
"github.com/BurntSushi/toml"
7+
)
8+
9+
type StaticConfig struct {
10+
DeniedResources []GroupVersionKind `toml:"denied_resources"`
11+
}
12+
13+
type GroupVersionKind struct {
14+
Group string `toml:"group"`
15+
Version string `toml:"version"`
16+
Kind string `toml:"kind,omitempty"`
17+
}
18+
19+
func ReadConfig(configPath string) (*StaticConfig, error) {
20+
configData, err := os.ReadFile(configPath)
21+
if err != nil {
22+
return nil, err
23+
}
24+
25+
var config *StaticConfig
26+
err = toml.Unmarshal(configData, &config)
27+
if err != nil {
28+
return nil, err
29+
}
30+
return config, nil
31+
}

pkg/config/config_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestReadConfig(t *testing.T) {
10+
tempDir := t.TempDir()
11+
12+
t.Run("ValidConfigFileWithDeniedResources", func(t *testing.T) {
13+
validConfigContent := `
14+
[[denied_resources]]
15+
group = "apps"
16+
version = "v1"
17+
kind = "Deployment"
18+
19+
[[denied_resources]]
20+
group = "rbac.authorization.k8s.io"
21+
version = "v1"
22+
`
23+
validConfigPath := filepath.Join(tempDir, "valid_denied_config.toml")
24+
err := os.WriteFile(validConfigPath, []byte(validConfigContent), 0644)
25+
if err != nil {
26+
t.Fatalf("Failed to write valid config file: %v", err)
27+
}
28+
29+
config, err := ReadConfig(validConfigPath)
30+
if err != nil {
31+
t.Fatalf("ReadConfig returned an error for a valid file: %v", err)
32+
}
33+
34+
if config == nil {
35+
t.Fatal("ReadConfig returned a nil config for a valid file")
36+
}
37+
38+
if len(config.DeniedResources) != 2 {
39+
t.Fatalf("Expected 2 denied resources, got %d", len(config.DeniedResources))
40+
}
41+
42+
if config.DeniedResources[0].Group != "apps" ||
43+
config.DeniedResources[0].Version != "v1" ||
44+
config.DeniedResources[0].Kind != "Deployment" {
45+
t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
46+
}
47+
})
48+
}

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"k8s.io/kubectl/pkg/util/i18n"
1717
"k8s.io/kubectl/pkg/util/templates"
1818

19+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
1920
"github.com/manusa/kubernetes-mcp-server/pkg/mcp"
2021
"github.com/manusa/kubernetes-mcp-server/pkg/output"
2122
"github.com/manusa/kubernetes-mcp-server/pkg/version"
@@ -53,6 +54,9 @@ type MCPServerOptions struct {
5354
ReadOnly bool
5455
DisableDestructive bool
5556

57+
ConfigPath string
58+
StaticConfig *config.StaticConfig
59+
5660
genericiooptions.IOStreams
5761
}
5862

@@ -86,6 +90,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
8690
},
8791
}
8892

93+
cmd.Flags().StringVar(&o.ConfigPath, "config", o.ConfigPath, "Path of the config file. Each profile has its set of defaults.")
8994
cmd.Flags().BoolVar(&o.Version, "version", o.Version, "Print version information and quit")
9095
cmd.Flags().IntVar(&o.LogLevel, "log-level", o.LogLevel, "Set the log level (from 0 to 9)")
9196
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 {
103108
func (m *MCPServerOptions) Complete() error {
104109
m.initializeLogging()
105110

111+
if m.ConfigPath != "" {
112+
cnf, err := config.ReadConfig(m.ConfigPath)
113+
if err != nil {
114+
return err
115+
}
116+
m.StaticConfig = cnf
117+
}
118+
106119
return nil
107120
}
108121

@@ -141,12 +154,13 @@ func (m *MCPServerOptions) Run() error {
141154
fmt.Fprintf(m.Out, "%s\n", version.Version)
142155
return nil
143156
}
144-
mcpServer, err := mcp.NewSever(mcp.Configuration{
157+
mcpServer, err := mcp.NewServer(mcp.Configuration{
145158
Profile: profile,
146159
ListOutput: listOutput,
147160
ReadOnly: m.ReadOnly,
148161
DisableDestructive: m.DisableDestructive,
149162
Kubeconfig: m.Kubeconfig,
163+
StaticConfig: m.StaticConfig,
150164
})
151165
if err != nil {
152166
return fmt.Errorf("Failed to initialize MCP server: %w\n", err)

pkg/kubernetes/kubernetes.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@ package kubernetes
22

33
import (
44
"context"
5+
"strings"
6+
57
"github.com/fsnotify/fsnotify"
6-
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
8+
79
v1 "k8s.io/api/core/v1"
810
"k8s.io/apimachinery/pkg/api/meta"
911
"k8s.io/apimachinery/pkg/runtime"
1012
"k8s.io/client-go/discovery"
1113
"k8s.io/client-go/discovery/cached/memory"
1214
"k8s.io/client-go/dynamic"
1315
"k8s.io/client-go/kubernetes"
14-
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
1516
"k8s.io/client-go/rest"
1617
"k8s.io/client-go/restmapper"
1718
"k8s.io/client-go/tools/clientcmd"
1819
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
1920
"k8s.io/klog/v2"
20-
"strings"
21+
22+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
23+
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
24+
25+
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
2126
)
2227

2328
const (
@@ -42,11 +47,14 @@ type Manager struct {
4247
discoveryClient discovery.CachedDiscoveryInterface
4348
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
4449
dynamicClient *dynamic.DynamicClient
50+
51+
StaticConfig *config.StaticConfig
4552
}
4653

47-
func NewManager(kubeconfig string) (*Manager, error) {
54+
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
4855
k8s := &Manager{
49-
Kubeconfig: kubeconfig,
56+
Kubeconfig: kubeconfig,
57+
StaticConfig: config,
5058
}
5159
if err := resolveKubernetesConfigurations(k8s); err != nil {
5260
return nil, err

pkg/kubernetes/resources.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
3333
if err != nil {
3434
return nil, err
3535
}
36+
37+
if !k.isAllowed(gvk) {
38+
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
39+
}
40+
3641
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
3742
isNamespaced, _ := k.isNamespaced(gvk)
3843
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
@@ -49,6 +54,11 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
4954
if err != nil {
5055
return nil, err
5156
}
57+
58+
if !k.isAllowed(gvk) {
59+
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
60+
}
61+
5262
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
5363
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
5464
namespace = k.NamespaceOrDefault(namespace)
@@ -75,6 +85,11 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
7585
if err != nil {
7686
return err
7787
}
88+
89+
if !k.isAllowed(gvk) {
90+
return fmt.Errorf("resource not allowed: %s", gvk.String())
91+
}
92+
7893
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
7994
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
8095
namespace = k.NamespaceOrDefault(namespace)
@@ -136,6 +151,11 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
136151
if rErr != nil {
137152
return nil, rErr
138153
}
154+
155+
if !k.isAllowed(&gvk) {
156+
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
157+
}
158+
139159
namespace := obj.GetNamespace()
140160
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
141161
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
@@ -163,6 +183,30 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer
163183
return &m.Resource, nil
164184
}
165185

186+
// isAllowed checks the resource is in denied list or not.
187+
// If it is in denied list, this function returns false.
188+
func (k *Kubernetes) isAllowed(gvk *schema.GroupVersionKind) bool {
189+
if k.manager.StaticConfig == nil {
190+
return true
191+
}
192+
193+
for _, val := range k.manager.StaticConfig.DeniedResources {
194+
// If kind is empty, that means Group/Version pair is denied entirely
195+
if val.Kind == "" {
196+
if gvk.Group == val.Group && gvk.Version == val.Version {
197+
return false
198+
}
199+
}
200+
if gvk.Group == val.Group &&
201+
gvk.Version == val.Version &&
202+
gvk.Kind == val.Kind {
203+
return false
204+
}
205+
}
206+
207+
return true
208+
}
209+
166210
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
167211
apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
168212
if err != nil {

pkg/mcp/common_test.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
78
"github.com/manusa/kubernetes-mcp-server/pkg/output"
89
"github.com/mark3labs/mcp-go/client"
910
"github.com/mark3labs/mcp-go/client/transport"
@@ -100,6 +101,7 @@ type mcpContext struct {
100101
listOutput output.Output
101102
readOnly bool
102103
disableDestructive bool
104+
staticConfig *config.StaticConfig
103105
clientOptions []transport.ClientOption
104106
before func(*mcpContext)
105107
after func(*mcpContext)
@@ -125,11 +127,26 @@ func (c *mcpContext) beforeEach(t *testing.T) {
125127
if c.before != nil {
126128
c.before(c)
127129
}
128-
if c.mcpServer, err = NewSever(Configuration{
130+
if c.staticConfig == nil {
131+
c.staticConfig = &config.StaticConfig{
132+
DeniedResources: []config.GroupVersionKind{
133+
{
134+
Version: "v1",
135+
Kind: "Secret",
136+
},
137+
{
138+
Group: "rbac.authorization.k8s.io",
139+
Version: "v1",
140+
},
141+
},
142+
}
143+
}
144+
if c.mcpServer, err = NewServer(Configuration{
129145
Profile: c.profile,
130146
ListOutput: c.listOutput,
131147
ReadOnly: c.readOnly,
132148
DisableDestructive: c.disableDestructive,
149+
StaticConfig: c.staticConfig,
133150
}); err != nil {
134151
t.Fatal(err)
135152
return
@@ -205,6 +222,10 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
205222
return fakeConfig
206223
}
207224

225+
func (c *mcpContext) withStaticConfig(config *config.StaticConfig) {
226+
c.staticConfig = config
227+
}
228+
208229
// withEnvTest sets up the environment for kubeconfig to be used with envTest
209230
func (c *mcpContext) withEnvTest() {
210231
c.withKubeConfig(envTestRestConfig)

pkg/mcp/mcp.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcp
22

33
import (
44
"context"
5+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
56
"net/http"
67

78
"github.com/mark3labs/mcp-go/mcp"
@@ -21,6 +22,8 @@ type Configuration struct {
2122
// When true, disable tools annotated with destructiveHint=true
2223
DisableDestructive bool
2324
Kubeconfig string
25+
26+
StaticConfig *config.StaticConfig
2427
}
2528

2629
type Server struct {
@@ -29,7 +32,7 @@ type Server struct {
2932
k *kubernetes.Manager
3033
}
3134

32-
func NewSever(configuration Configuration) (*Server, error) {
35+
func NewServer(configuration Configuration) (*Server, error) {
3336
s := &Server{
3437
configuration: &configuration,
3538
server: server.NewMCPServer(
@@ -45,11 +48,12 @@ func NewSever(configuration Configuration) (*Server, error) {
4548
return nil, err
4649
}
4750
s.k.WatchKubeConfig(s.reloadKubernetesClient)
51+
4852
return s, nil
4953
}
5054

5155
func (s *Server) reloadKubernetesClient() error {
52-
k, err := kubernetes.NewManager(s.configuration.Kubeconfig)
56+
k, err := kubernetes.NewManager(s.configuration.Kubeconfig, s.configuration.StaticConfig)
5357
if err != nil {
5458
return err
5559
}

0 commit comments

Comments
 (0)