Skip to content

Commit 601f882

Browse files
committed
Introduce toml configuration file with a set of deny list
1 parent 25608da commit 601f882

File tree

11 files changed

+176
-8
lines changed

11 files changed

+176
-8
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/conf.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# A list of denied Kubernetes resources in Group/Version/Kind format.
2+
# If a resource is in this list, your MCP server should deny all operations
3+
# on that resource type across all namespaces.
4+
[[denied_resources]]
5+
group = ""
6+
version = "v1"
7+
kind = "ServiceAccount"
8+
9+
[[denied_resources]]
10+
group = ""
11+
version = "v1"
12+
kind = "Secret"
13+
14+
[[denied_resources]]
15+
group = "rbac.authorization.k8s.io"
16+
version = "v1"

pkg/config/config.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package config
2+
3+
import (
4+
"embed"
5+
"io/fs"
6+
"os"
7+
8+
"github.com/BurntSushi/toml"
9+
)
10+
11+
//go:embed conf.toml
12+
var defaultConfigFile embed.FS
13+
14+
type StaticConfig struct {
15+
DeniedResources []GroupVersionKind `toml:"denied_resources"`
16+
}
17+
18+
type GroupVersionKind struct {
19+
Group string `toml:"group"`
20+
Version string `toml:"version"`
21+
Kind string `toml:"kind,omitempty"`
22+
}
23+
24+
func ReadConfig(configPath string) (*StaticConfig, error) {
25+
var configData []byte
26+
var err error
27+
if configPath == "" {
28+
configData, err = fs.ReadFile(defaultConfigFile, "conf.toml")
29+
if err != nil {
30+
return nil, err
31+
}
32+
} else
33+
{
34+
configData, err = os.ReadFile(configPath)
35+
if err != nil {
36+
return nil, err
37+
}
38+
}
39+
40+
var config *StaticConfig
41+
err = toml.Unmarshal(configData, &config)
42+
if err != nil {
43+
return nil, err
44+
}
45+
return config, nil
46+
}

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

Lines changed: 13 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. Defaults to conf.toml.")
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,12 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
103108
func (m *MCPServerOptions) Complete() error {
104109
m.initializeLogging()
105110

111+
cnf, err := config.ReadConfig(m.ConfigPath)
112+
if err != nil {
113+
return err
114+
}
115+
m.StaticConfig = cnf
116+
106117
return nil
107118
}
108119

@@ -141,12 +152,13 @@ func (m *MCPServerOptions) Run() error {
141152
fmt.Fprintf(m.Out, "%s\n", version.Version)
142153
return nil
143154
}
144-
mcpServer, err := mcp.NewSever(mcp.Configuration{
155+
mcpServer, err := mcp.NewServer(mcp.Configuration{
145156
Profile: profile,
146157
ListOutput: listOutput,
147158
ReadOnly: m.ReadOnly,
148159
DisableDestructive: m.DisableDestructive,
149160
Kubeconfig: m.Kubeconfig,
161+
StaticConfig: m.StaticConfig,
150162
})
151163
if err != nil {
152164
return fmt.Errorf("Failed to initialize MCP server: %w\n", err)

pkg/kubernetes/kubernetes.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kubernetes
33
import (
44
"context"
55
"github.com/fsnotify/fsnotify"
6+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
67
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
78
v1 "k8s.io/api/core/v1"
89
"k8s.io/apimachinery/pkg/api/meta"
@@ -42,11 +43,14 @@ type Manager struct {
4243
discoveryClient discovery.CachedDiscoveryInterface
4344
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
4445
dynamicClient *dynamic.DynamicClient
46+
47+
StaticConfig *config.StaticConfig
4548
}
4649

47-
func NewManager(kubeconfig string) (*Manager, error) {
50+
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
4851
k8s := &Manager{
49-
Kubeconfig: kubeconfig,
52+
Kubeconfig: kubeconfig,
53+
StaticConfig: config,
5054
}
5155
if err := resolveKubernetesConfigurations(k8s); err != nil {
5256
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: 5 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(
@@ -49,7 +52,7 @@ func NewSever(configuration Configuration) (*Server, error) {
4952
}
5053

5154
func (s *Server) reloadKubernetesClient() error {
52-
k, err := kubernetes.NewManager(s.configuration.Kubeconfig)
55+
k, err := kubernetes.NewManager(s.configuration.Kubeconfig, s.configuration.StaticConfig)
5356
if err != nil {
5457
return err
5558
}

pkg/mcp/mcp_test.go

Lines changed: 2 additions & 0 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
"github.com/mark3labs/mcp-go/client"
67
"github.com/mark3labs/mcp-go/mcp"
78
"net/http"
@@ -96,6 +97,7 @@ func TestSseHeaders(t *testing.T) {
9697
defer mockServer.Close()
9798
before := func(c *mcpContext) {
9899
c.withKubeConfig(mockServer.config)
100+
c.withStaticConfig(&config.StaticConfig{})
99101
c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"}))
100102
}
101103
pathHeaders := make(map[string]http.Header, 0)

pkg/mcp/resources_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ func TestResourcesList(t *testing.T) {
5454
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
5555
}
5656
})
57+
t.Run("resources_list with a resource in denied list as kind", func(t *testing.T) {
58+
toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"})
59+
if !toolResult.IsError {
60+
t.Fatalf("call tool should fail")
61+
}
62+
//failed to list resources: resource not allowed: /v1, Kind=Secret
63+
if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: resource not allowed: /v1, Kind=Secret` {
64+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
65+
}
66+
})
67+
t.Run("resources_list with a resource in denied list as group", func(t *testing.T) {
68+
toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"})
69+
if !toolResult.IsError {
70+
t.Fatalf("call tool should fail")
71+
}
72+
//failed to list resources: resource not allowed: /v1, Kind=Secret
73+
if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role` {
74+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
75+
}
76+
})
5777
namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
5878
t.Run("resources_list returns namespaces", func(t *testing.T) {
5979
if err != nil {

0 commit comments

Comments
 (0)