feat(config): --disable-destructive exposes tools not annotated with destructiveHint=true

This commit is contained in:
Marc Nuri
2025-05-26 18:36:45 +02:00
committed by GitHub
parent 5f279a81d8
commit e6f20fc777
12 changed files with 80 additions and 23 deletions

View File

@@ -148,12 +148,13 @@ uvx kubernetes-mcp-server@latest --help
### Configuration Options
| Option | Description |
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
| `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
| `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. |
| Option | Description |
|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
| `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). |
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
| `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. |
| `--disable-destructive` | If set, the MCP server will disable all destructive operations (delete, update, etc.) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without accidentally making changes. This option has no effect when `--read-only` is used. |
## 🛠️ Tools <a id="tools"></a>

View File

@@ -49,14 +49,16 @@ Kubernetes Model Context Protocol (MCP) server
klog.V(1).Info("Starting kubernetes-mcp-server")
klog.V(1).Infof(" - Profile: %s", profile.GetName())
klog.V(1).Infof(" - Read-only mode: %t", viper.GetBool("read-only"))
klog.V(1).Infof(" - Disable destructive tools: %t", viper.GetBool("disable-destructive"))
if viper.GetBool("version") {
fmt.Println(version.Version)
return
}
mcpServer, err := mcp.NewSever(mcp.Configuration{
Profile: profile,
ReadOnly: viper.GetBool("read-only"),
Kubeconfig: viper.GetString("kubeconfig"),
Profile: profile,
ReadOnly: viper.GetBool("read-only"),
DisableDestructive: viper.GetBool("disable-destructive"),
Kubeconfig: viper.GetString("kubeconfig"),
})
if err != nil {
fmt.Printf("Failed to initialize MCP server: %v\n", err)
@@ -127,5 +129,6 @@ func init() {
rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication")
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
rootCmd.Flags().Bool("read-only", false, "If true, only tools annotated with readOnlyHint=true are exposed")
rootCmd.Flags().Bool("disable-destructive", false, "If true, tools annotated with destructiveHint=true are disabled")
_ = viper.BindPFlags(rootCmd.Flags())
}

View File

@@ -43,3 +43,11 @@ func TestDefaultReadOnly(t *testing.T) {
t.Fatalf("Expected read-only mode false, got %s %v", out, err)
}
}
func TestDefaultDisableDestructive(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, " - Disable destructive tools: false") {
t.Fatalf("Expected disable destructive false, got %s %v", out, err)
}
}

View File

@@ -94,16 +94,17 @@ func TestMain(m *testing.M) {
}
type mcpContext struct {
profile Profile
readOnly bool
before func(*mcpContext)
after func(*mcpContext)
ctx context.Context
tempDir string
cancel context.CancelFunc
mcpServer *Server
mcpHttpServer *httptest.Server
mcpClient *client.Client
profile Profile
readOnly bool
disableDestructive bool
before func(*mcpContext)
after func(*mcpContext)
ctx context.Context
tempDir string
cancel context.CancelFunc
mcpServer *Server
mcpHttpServer *httptest.Server
mcpClient *client.Client
}
func (c *mcpContext) beforeEach(t *testing.T) {
@@ -117,7 +118,9 @@ func (c *mcpContext) beforeEach(t *testing.T) {
if c.before != nil {
c.before(c)
}
if c.mcpServer, err = NewSever(Configuration{Profile: c.profile, ReadOnly: c.readOnly}); err != nil {
if c.mcpServer, err = NewSever(Configuration{
Profile: c.profile, ReadOnly: c.readOnly, DisableDestructive: c.disableDestructive,
}); err != nil {
t.Fatal(err)
return
}

View File

@@ -18,6 +18,7 @@ func (s *Server) initConfiguration() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Configuration: View"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.configurationView},
}

View File

@@ -16,6 +16,7 @@ func (s *Server) initEvents() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Events: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.eventsList},
}

View File

@@ -29,6 +29,7 @@ func (s *Server) initHelm() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Helm: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), s.helmList},
{mcp.NewTool("helm_uninstall",

View File

@@ -10,8 +10,10 @@ import (
type Configuration struct {
Profile Profile
// When true, expose only tools annotated with readOnlyHint=true
ReadOnly bool
Kubeconfig string
ReadOnly bool
// When true, disable tools annotated with destructiveHint=true
DisableDestructive bool
Kubeconfig string
}
type Server struct {
@@ -39,6 +41,10 @@ func NewSever(configuration Configuration) (*Server, error) {
return s, nil
}
func isFalse(value *bool) bool {
return value == nil || !*value
}
func (s *Server) reloadKubernetesClient() error {
k, err := kubernetes.NewKubernetes(s.configuration.Kubeconfig)
if err != nil {
@@ -47,7 +53,10 @@ func (s *Server) reloadKubernetesClient() error {
s.k = k
applicableTools := make([]server.ServerTool, 0)
for _, tool := range s.configuration.Profile.GetTools(s) {
if s.configuration.ReadOnly && (tool.Tool.Annotations.ReadOnlyHint == nil || !*tool.Tool.Annotations.ReadOnlyHint) {
if s.configuration.ReadOnly && isFalse(tool.Tool.Annotations.ReadOnlyHint) {
continue
}
if s.configuration.DisableDestructive && isFalse(tool.Tool.Annotations.ReadOnlyHint) && !isFalse(tool.Tool.Annotations.DestructiveHint) {
continue
}
applicableTools = append(applicableTools, tool)

View File

@@ -62,6 +62,28 @@ func TestReadOnly(t *testing.T) {
if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint {
t.Errorf("Tool %s is not read-only but should be", tool.Name)
}
if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
t.Errorf("Tool %s is destructive but should not be in read-only mode", tool.Name)
}
}
})
})
}
func TestDisableDestructive(t *testing.T) {
disableDestructiveServer := func(c *mcpContext) { c.disableDestructive = true }
testCaseWithContext(t, &mcpContext{before: disableDestructiveServer}, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
t.Fatalf("call ListTools failed %v", err)
}
})
t.Run("ListTools does not return destructive tools", func(t *testing.T) {
for _, tool := range tools.Tools {
if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
t.Errorf("Tool %s is destructive but should not be", tool.Name)
}
}
})
})

View File

@@ -15,6 +15,7 @@ func (s *Server) initNamespaces() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Namespaces: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.namespacesList,
})
@@ -25,6 +26,7 @@ func (s *Server) initNamespaces() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Projects: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.projectsList,
})

View File

@@ -17,6 +17,7 @@ func (s *Server) initPods() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Pods: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsListInAllNamespaces},
{Tool: mcp.NewTool("pods_list_in_namespace",
@@ -26,6 +27,7 @@ func (s *Server) initPods() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Pods: List in Namespace"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsListInNamespace},
{Tool: mcp.NewTool("pods_get",
@@ -35,6 +37,7 @@ func (s *Server) initPods() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Pods: Get"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsGet},
{Tool: mcp.NewTool("pods_delete",
@@ -81,6 +84,7 @@ func (s *Server) initPods() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Pods: Log"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.podsLog},
{Tool: mcp.NewTool("pods_run",

View File

@@ -35,6 +35,7 @@ func (s *Server) initResources() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Resources: List"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesList},
{Tool: mcp.NewTool("resources_get",
@@ -55,6 +56,7 @@ func (s *Server) initResources() []server.ServerTool {
// Tool annotations
mcp.WithTitleAnnotation("Resources: Get"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(true),
), Handler: s.resourcesGet},
{Tool: mcp.NewTool("resources_create_or_update",