From 324c678e3a39d8ba29fbeaddf29987ba1d69582c Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 26 May 2025 16:06:45 +0200 Subject: [PATCH] feat(config): --read-only mode flag exposes only read-only annotated tools --- README.md | 1 + pkg/kubernetes-mcp-server/cmd/root.go | 6 +++++- pkg/kubernetes-mcp-server/cmd/root_test.go | 10 +++++++++- pkg/mcp/common_test.go | 3 ++- pkg/mcp/mcp.go | 13 +++++++++++-- pkg/mcp/mcp_test.go | 19 +++++++++++++++++++ 6 files changed, 47 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e56c7103..c821bb99 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ uvx kubernetes-mcp-server@latest --help | `--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. | ## 🛠️ Tools diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 5b44df2e..c4014292 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -46,13 +46,16 @@ Kubernetes Model Context Protocol (MCP) server fmt.Printf("Invalid profile name: %s, valid names are: %s\n", viper.GetString("profile"), strings.Join(mcp.ProfileNames, ", ")) os.Exit(1) } - klog.V(1).Infof("Starting kubernetes-mcp-server with profile: %s", profile.GetName()) + 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")) 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"), }) if err != nil { @@ -123,5 +126,6 @@ func init() { rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)") 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") _ = viper.BindPFlags(rootCmd.Flags()) } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 06362ea2..3b7cc477 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -31,7 +31,15 @@ func TestVersion(t *testing.T) { func TestDefaultProfile(t *testing.T) { rootCmd.SetArgs([]string{"--version", "--log-level=1"}) out, err := captureOutput(rootCmd.Execute) - if !strings.Contains(out, "Starting kubernetes-mcp-server with profile: full") { + if !strings.Contains(out, "- Profile: full") { t.Fatalf("Expected profile 'full', got %s %v", out, err) } } + +func TestDefaultReadOnly(t *testing.T) { + rootCmd.SetArgs([]string{"--version", "--log-level=1"}) + out, err := captureOutput(rootCmd.Execute) + if !strings.Contains(out, " - Read-only mode: false") { + t.Fatalf("Expected read-only mode false, got %s %v", out, err) + } +} diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index 0842dd7e..3e7b849e 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -95,6 +95,7 @@ func TestMain(m *testing.M) { type mcpContext struct { profile Profile + readOnly bool before func(*mcpContext) after func(*mcpContext) ctx context.Context @@ -116,7 +117,7 @@ func (c *mcpContext) beforeEach(t *testing.T) { if c.before != nil { c.before(c) } - if c.mcpServer, err = NewSever(Configuration{Profile: c.profile}); err != nil { + if c.mcpServer, err = NewSever(Configuration{Profile: c.profile, ReadOnly: c.readOnly}); err != nil { t.Fatal(err) return } diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index f156572d..4cc8ec3b 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -8,7 +8,9 @@ import ( ) type Configuration struct { - Profile Profile + Profile Profile + // When true, expose only tools annotated with readOnlyHint=true + ReadOnly bool Kubeconfig string } @@ -43,7 +45,14 @@ func (s *Server) reloadKubernetesClient() error { return err } s.k = k - s.server.SetTools(s.configuration.Profile.GetTools(s)...) + 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) { + continue + } + applicableTools = append(applicableTools, tool) + } + s.server.SetTools(applicableTools...) return nil } diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 347123bf..c7d1277a 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -47,3 +47,22 @@ func TestWatchKubeConfig(t *testing.T) { }) }) } + +func TestReadOnly(t *testing.T) { + readOnlyServer := func(c *mcpContext) { c.readOnly = true } + testCaseWithContext(t, &mcpContext{before: readOnlyServer}, 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 returns only read-only tools", func(t *testing.T) { + for _, tool := range tools.Tools { + if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint { + t.Errorf("Tool %s is not read-only but should be", tool.Name) + } + } + }) + }) +}