Skip to content

Commit 5f279a8

Browse files
authored
feat(config): --read-only mode flag exposes only read-only annotated tools
1 parent 219f1b4 commit 5f279a8

File tree

6 files changed

+47
-5
lines changed

6 files changed

+47
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ uvx kubernetes-mcp-server@latest --help
153153
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
154154
| `--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). |
155155
| `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). |
156+
| `--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. |
156157

157158
## 🛠️ Tools <a id="tools"></a>
158159

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ Kubernetes Model Context Protocol (MCP) server
4646
fmt.Printf("Invalid profile name: %s, valid names are: %s\n", viper.GetString("profile"), strings.Join(mcp.ProfileNames, ", "))
4747
os.Exit(1)
4848
}
49-
klog.V(1).Infof("Starting kubernetes-mcp-server with profile: %s", profile.GetName())
49+
klog.V(1).Info("Starting kubernetes-mcp-server")
50+
klog.V(1).Infof(" - Profile: %s", profile.GetName())
51+
klog.V(1).Infof(" - Read-only mode: %t", viper.GetBool("read-only"))
5052
if viper.GetBool("version") {
5153
fmt.Println(version.Version)
5254
return
5355
}
5456
mcpServer, err := mcp.NewSever(mcp.Configuration{
5557
Profile: profile,
58+
ReadOnly: viper.GetBool("read-only"),
5659
Kubeconfig: viper.GetString("kubeconfig"),
5760
})
5861
if err != nil {
@@ -123,5 +126,6 @@ func init() {
123126
rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
124127
rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication")
125128
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
129+
rootCmd.Flags().Bool("read-only", false, "If true, only tools annotated with readOnlyHint=true are exposed")
126130
_ = viper.BindPFlags(rootCmd.Flags())
127131
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,15 @@ func TestVersion(t *testing.T) {
3131
func TestDefaultProfile(t *testing.T) {
3232
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
3333
out, err := captureOutput(rootCmd.Execute)
34-
if !strings.Contains(out, "Starting kubernetes-mcp-server with profile: full") {
34+
if !strings.Contains(out, "- Profile: full") {
3535
t.Fatalf("Expected profile 'full', got %s %v", out, err)
3636
}
3737
}
38+
39+
func TestDefaultReadOnly(t *testing.T) {
40+
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
41+
out, err := captureOutput(rootCmd.Execute)
42+
if !strings.Contains(out, " - Read-only mode: false") {
43+
t.Fatalf("Expected read-only mode false, got %s %v", out, err)
44+
}
45+
}

pkg/mcp/common_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ func TestMain(m *testing.M) {
9595

9696
type mcpContext struct {
9797
profile Profile
98+
readOnly bool
9899
before func(*mcpContext)
99100
after func(*mcpContext)
100101
ctx context.Context
@@ -116,7 +117,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
116117
if c.before != nil {
117118
c.before(c)
118119
}
119-
if c.mcpServer, err = NewSever(Configuration{Profile: c.profile}); err != nil {
120+
if c.mcpServer, err = NewSever(Configuration{Profile: c.profile, ReadOnly: c.readOnly}); err != nil {
120121
t.Fatal(err)
121122
return
122123
}

pkg/mcp/mcp.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
)
99

1010
type Configuration struct {
11-
Profile Profile
11+
Profile Profile
12+
// When true, expose only tools annotated with readOnlyHint=true
13+
ReadOnly bool
1214
Kubeconfig string
1315
}
1416

@@ -43,7 +45,14 @@ func (s *Server) reloadKubernetesClient() error {
4345
return err
4446
}
4547
s.k = k
46-
s.server.SetTools(s.configuration.Profile.GetTools(s)...)
48+
applicableTools := make([]server.ServerTool, 0)
49+
for _, tool := range s.configuration.Profile.GetTools(s) {
50+
if s.configuration.ReadOnly && (tool.Tool.Annotations.ReadOnlyHint == nil || !*tool.Tool.Annotations.ReadOnlyHint) {
51+
continue
52+
}
53+
applicableTools = append(applicableTools, tool)
54+
}
55+
s.server.SetTools(applicableTools...)
4756
return nil
4857
}
4958

pkg/mcp/mcp_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,22 @@ func TestWatchKubeConfig(t *testing.T) {
4747
})
4848
})
4949
}
50+
51+
func TestReadOnly(t *testing.T) {
52+
readOnlyServer := func(c *mcpContext) { c.readOnly = true }
53+
testCaseWithContext(t, &mcpContext{before: readOnlyServer}, func(c *mcpContext) {
54+
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
55+
t.Run("ListTools returns tools", func(t *testing.T) {
56+
if err != nil {
57+
t.Fatalf("call ListTools failed %v", err)
58+
}
59+
})
60+
t.Run("ListTools returns only read-only tools", func(t *testing.T) {
61+
for _, tool := range tools.Tools {
62+
if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint {
63+
t.Errorf("Tool %s is not read-only but should be", tool.Name)
64+
}
65+
}
66+
})
67+
})
68+
}

0 commit comments

Comments
 (0)