feat(config): --read-only mode flag exposes only read-only annotated tools

This commit is contained in:
Marc Nuri
2025-05-26 16:13:36 +02:00
committed by GitHub
parent 219f1b470c
commit 5f279a81d8
6 changed files with 47 additions and 5 deletions

View File

@@ -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 <a id="tools"></a>

View File

@@ -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())
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}
})
})
}