Repository: containers/kubernetes-mcp-server

Issue:
Adding namespace and tools control arguments
## Description

Adding an argument (like --namespace) to select a namespace to limit all commands to will be great as a security feature to limit the access to the mcp server to a single namespace even if the kubeconfig has access to multiple (or cluster level access).
Another argument that will help with access control is one to limit tools that can be used with the mcp server (like --exclude-tools or --include-tools). This argument will limit the tools the mcp server can access (like --read-only) but it will enable each user to choose which tools to expose.

My use case is that I want to create an API that will allow users to send questions about their namespaces and will return an answer. The namespace is checked with their permissions but the can ask in the question body to get another namespace's resources. Another thing is I would want to exclude the configuration_view tool as well add the --read-only flag.

Hints from issue comments:
@ardaguclu: @N0YU0 I think this new profile proposal https://github.com/manusa/kubernetes-mcp-server/pull/134 would mitigate some of your concerns (i.e. excluding `configuration_view` tool).

After thinking on this, maybe instead of adding a new profile that are mostly a set of static rules per each requirement. Maybe we can provide a `custom` profile that is fully up to the passed configuration file.
* included tools list
* namespace (default all)
* denied resources

@manusa WDYT?@manusa: We need to think about it.

It might be interesting to deal with all this in the scope of configuration files #131 + #137

If we check the GitHub MCP server, they introduced the concept of [_Dynamic tools_](https://github.com/github/github-mcp-server/issues/189) ([implementation](https://github.com/github/github-mcp-server/pull/188)) which is very similar to some of the features we want to tackle with **profiles** and **configuration file**.

My initial idea would be to create a set of well-defined tool groups for example:
- resources_readonly
- resources_destructive
- pods_readonly
- pods_destructive
- helm_readonly
- ...

The `StaticConfig` can then have a field toolSets which defines the tools available.
In addition, and in scope of #137, every other configuration flag should be settable via the config file too. ---> StaticConfig is the single source of truth for the MCP Server configuration+behavior.

Finally, continuing on #134, we can provide a set of predefined profiles with defaults (full, full-safe) for all values.

Still (see #137), **some** cmd flags are preserved for one-off overrides.

Config inheritance/extension should also be taken into account. i.e. a user wants to have the `full` behavior, but just limit the namespace.
@ardaguclu: @N0YU0 allow/deny tool list makes sense.  However, after thinking on this, I'm not sure I fully understand the namespace part.

Namespace is dynamic and changing based on each user. So that statically defining namespace in the config file of MCP Server will limit the entire MCP Server to a set of namespaces. Is this similar to what you proposed?.@N0YU0: @ardaguclu The namespace flag that I proposed was more so that you could define the scoped namespace that the mcp server could access.
I'm not sure if this would be a good addition to the configuration file, but as a cli flag it would help me immensely.
I use the mcp server in stdio mode every time a request is received on my server. For security purposes I would like to limit the mcp server that is running per request with the namespace given in the request (instead of trying to detect prompt injection of another namespace in the request body). I'm not sure if there is a better way to limit the call to the mcp for a specific namespace.@ardaguclu: What about non-namespaced resources?@N0YU0: I think ignoring it with cluster scoped resources, like the behavior of kubectl with --namespace flag with cluster scoped resources (e.g. kubectl get --namespace foo nodes) would be fine. @ardaguclu: @manusa so we don't need namespace based restriction? or we'll handle it in a different issue?.@manusa: > [@manusa](https://github.com/manusa) so we don't need namespace based restriction? or we'll handle it in a different issue?.

Sorry, I missed this comment.

Do we still want/need to provide the namespace restriction?@ardaguclu: > > [@manusa](https://github.com/manusa) so we don't need namespace based restriction? or we'll handle it in a different issue?.
> 
> Sorry, I missed this comment.
> 
> Do we still want/need to provide the namespace restriction?

In my opinion, namespace restriction can be achieved by relying on RBAC rules in the cluster. If cluster admin correctly restricts access of the users to other namespaces, API server will automatically reject the requests already. We don't need to add such restriction in MCP Server.@manusa: > In my opinion, namespace restriction can be achieved by relying on RBAC rules in the cluster. If cluster admin correctly restricts access of the users to other namespaces, API server will automatically reject the requests already. We don't need to add such restriction in MCP Server.

👍 This is precisely how I see it.
By adding _client-side_ restrictions we're essentially duplicating the logic of RBAC and other Kubernetes components (with the maintenance burden this implies).

Once we have the entire OAuth flow working, we should document some of the common scenarios and how to achieve them.
@ardaguclu: > Once we have the entire OAuth flow working, we should document some of the common scenarios and how to achieve them.

Fully agree. Documenting how hardening the security, recommended configurations, etc.

Solution patch:
diff --git a/.gitignore b/.gitignore
index 1d4fb59..8b36a25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 .idea/
+.vscode/
 .docusaurus/
 node_modules/
 
diff --git a/pkg/config/config.go b/pkg/config/config.go
index f6471ea..af3f0ae 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -8,6 +8,15 @@ import (
 
 type StaticConfig struct {
 	DeniedResources []GroupVersionKind `toml:"denied_resources"`
+
+	LogLevel           int    `toml:"log_level,omitempty"`
+	SSEPort            int    `toml:"sse_port,omitempty"`
+	HTTPPort           int    `toml:"http_port,omitempty"`
+	SSEBaseURL         string `toml:"sse_base_url,omitempty"`
+	KubeConfig         string `toml:"kubeconfig,omitempty"`
+	ListOutput         string `toml:"list_output,omitempty"`
+	ReadOnly           bool   `toml:"read_only,omitempty"`
+	DisableDestructive bool   `toml:"disable_destructive,omitempty"`
 }
 
 type GroupVersionKind struct {
diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go
index e59b7c2..9d36fe3 100644
--- a/pkg/kubernetes-mcp-server/cmd/root.go
+++ b/pkg/kubernetes-mcp-server/cmd/root.go
@@ -62,9 +62,10 @@ type MCPServerOptions struct {
 
 func NewMCPServerOptions(streams genericiooptions.IOStreams) *MCPServerOptions {
 	return &MCPServerOptions{
-		IOStreams:  streams,
-		Profile:    "full",
-		ListOutput: "table",
+		IOStreams:    streams,
+		Profile:      "full",
+		ListOutput:   "table",
+		StaticConfig: &config.StaticConfig{},
 	}
 }
 
@@ -76,7 +77,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
 		Long:    long,
 		Example: examples,
 		RunE: func(c *cobra.Command, args []string) error {
-			if err := o.Complete(); err != nil {
+			if err := o.Complete(c); err != nil {
 				return err
 			}
 			if err := o.Validate(); err != nil {
@@ -98,16 +99,14 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
 	cmd.Flags().StringVar(&o.SSEBaseUrl, "sse-base-url", o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
 	cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication")
 	cmd.Flags().StringVar(&o.Profile, "profile", o.Profile, "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
-	cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+")")
+	cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.")
 	cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed")
 	cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled")
 
 	return cmd
 }
 
-func (m *MCPServerOptions) Complete() error {
-	m.initializeLogging()
-
+func (m *MCPServerOptions) Complete(cmd *cobra.Command) error {
 	if m.ConfigPath != "" {
 		cnf, err := config.ReadConfig(m.ConfigPath)
 		if err != nil {
@@ -116,16 +115,47 @@ func (m *MCPServerOptions) Complete() error {
 		m.StaticConfig = cnf
 	}
 
+	m.loadFlags(cmd)
+
+	m.initializeLogging()
+
 	return nil
 }
 
+func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
+	if cmd.Flag("log-level").Changed {
+		m.StaticConfig.LogLevel = m.LogLevel
+	}
+	if cmd.Flag("sse-port").Changed {
+		m.StaticConfig.SSEPort = m.SSEPort
+	}
+	if cmd.Flag("http-port").Changed {
+		m.StaticConfig.HTTPPort = m.HttpPort
+	}
+	if cmd.Flag("sse-base-url").Changed {
+		m.StaticConfig.SSEBaseURL = m.SSEBaseUrl
+	}
+	if cmd.Flag("kubeconfig").Changed {
+		m.StaticConfig.KubeConfig = m.Kubeconfig
+	}
+	if cmd.Flag("list-output").Changed || m.StaticConfig.ListOutput == "" {
+		m.StaticConfig.ListOutput = m.ListOutput
+	}
+	if cmd.Flag("read-only").Changed {
+		m.StaticConfig.ReadOnly = m.ReadOnly
+	}
+	if cmd.Flag("disable-destructive").Changed {
+		m.StaticConfig.DisableDestructive = m.DisableDestructive
+	}
+}
+
 func (m *MCPServerOptions) initializeLogging() {
 	flagSet := flag.NewFlagSet("klog", flag.ContinueOnError)
 	klog.InitFlags(flagSet)
 	loggerOptions := []textlogger.ConfigOption{textlogger.Output(m.Out)}
-	if m.LogLevel >= 0 {
-		loggerOptions = append(loggerOptions, textlogger.Verbosity(m.LogLevel))
-		_ = flagSet.Parse([]string{"--v", strconv.Itoa(m.LogLevel)})
+	if m.StaticConfig.LogLevel >= 0 {
+		loggerOptions = append(loggerOptions, textlogger.Verbosity(m.StaticConfig.LogLevel))
+		_ = flagSet.Parse([]string{"--v", strconv.Itoa(m.StaticConfig.LogLevel)})
 	}
 	logger := textlogger.NewLogger(textlogger.NewConfig(loggerOptions...))
 	klog.SetLoggerWithOptions(logger)
@@ -140,16 +170,16 @@ func (m *MCPServerOptions) Run() error {
 	if profile == nil {
 		return fmt.Errorf("Invalid profile name: %s, valid names are: %s\n", m.Profile, strings.Join(mcp.ProfileNames, ", "))
 	}
-	listOutput := output.FromString(m.ListOutput)
+	listOutput := output.FromString(m.StaticConfig.ListOutput)
 	if listOutput == nil {
-		return fmt.Errorf("Invalid output name: %s, valid names are: %s\n", m.ListOutput, strings.Join(output.Names, ", "))
+		return fmt.Errorf("Invalid output name: %s, valid names are: %s\n", m.StaticConfig.ListOutput, strings.Join(output.Names, ", "))
 	}
 	klog.V(1).Info("Starting kubernetes-mcp-server")
 	klog.V(1).Infof(" - Config: %s", m.ConfigPath)
 	klog.V(1).Infof(" - Profile: %s", profile.GetName())
 	klog.V(1).Infof(" - ListOutput: %s", listOutput.GetName())
-	klog.V(1).Infof(" - Read-only mode: %t", m.ReadOnly)
-	klog.V(1).Infof(" - Disable destructive tools: %t", m.DisableDestructive)
+	klog.V(1).Infof(" - Read-only mode: %t", m.StaticConfig.ReadOnly)
+	klog.V(1).Infof(" - Disable destructive tools: %t", m.StaticConfig.DisableDestructive)
 
 	if m.Version {
 		_, _ = fmt.Fprintf(m.Out, "%s\n", version.Version)
@@ -158,9 +188,9 @@ func (m *MCPServerOptions) Run() error {
 	mcpServer, err := mcp.NewServer(mcp.Configuration{
 		Profile:            profile,
 		ListOutput:         listOutput,
-		ReadOnly:           m.ReadOnly,
-		DisableDestructive: m.DisableDestructive,
-		Kubeconfig:         m.Kubeconfig,
+		ReadOnly:           m.StaticConfig.ReadOnly,
+		DisableDestructive: m.StaticConfig.DisableDestructive,
+		Kubeconfig:         m.StaticConfig.KubeConfig,
 		StaticConfig:       m.StaticConfig,
 	})
 	if err != nil {
@@ -170,19 +200,19 @@ func (m *MCPServerOptions) Run() error {
 
 	ctx := context.Background()
 
-	if m.SSEPort > 0 {
-		sseServer := mcpServer.ServeSse(m.SSEBaseUrl)
+	if m.StaticConfig.SSEPort > 0 {
+		sseServer := mcpServer.ServeSse(m.StaticConfig.SSEBaseURL)
 		defer func() { _ = sseServer.Shutdown(ctx) }()
-		klog.V(0).Infof("SSE server starting on port %d and path /sse", m.SSEPort)
-		if err := sseServer.Start(fmt.Sprintf(":%d", m.SSEPort)); err != nil {
+		klog.V(0).Infof("SSE server starting on port %d and path /sse", m.StaticConfig.SSEPort)
+		if err := sseServer.Start(fmt.Sprintf(":%d", m.StaticConfig.SSEPort)); err != nil {
 			return fmt.Errorf("failed to start SSE server: %w\n", err)
 		}
 	}
 
-	if m.HttpPort > 0 {
+	if m.StaticConfig.HTTPPort > 0 {
 		httpServer := mcpServer.ServeHTTP()
-		klog.V(0).Infof("Streaming HTTP server starting on port %d and path /mcp", m.HttpPort)
-		if err := httpServer.Start(fmt.Sprintf(":%d", m.HttpPort)); err != nil {
+		klog.V(0).Infof("Streaming HTTP server starting on port %d and path /mcp", m.StaticConfig.HTTPPort)
+		if err := httpServer.Start(fmt.Sprintf(":%d", m.StaticConfig.HTTPPort)); err != nil {
 			return fmt.Errorf("failed to start streaming HTTP server: %w\n", err)
 		}
 	}



Test patch:
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index bf862ef..1aac25f 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -50,6 +50,13 @@ kind = "Role
 
 func TestReadConfigValid(t *testing.T) {
 	validConfigPath := writeConfig(t, `
+log_level = 1
+sse_port = 9999
+kubeconfig = "test"
+list_output = "yaml"
+read_only = true
+disable_destructive = false
+
 [[denied_resources]]
 group = "apps"
 version = "v1"
@@ -78,6 +85,30 @@ version = "v1"
 			config.DeniedResources[0].Kind != "Deployment" {
 			t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0])
 		}
+		if config.LogLevel != 1 {
+			t.Fatalf("Unexpected log level: %v", config.LogLevel)
+		}
+		if config.SSEPort != 9999 {
+			t.Fatalf("Unexpected sse_port value: %v", config.SSEPort)
+		}
+		if config.SSEBaseURL != "" {
+			t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL)
+		}
+		if config.HTTPPort != 0 {
+			t.Fatalf("Unexpected http_port value: %v", config.HTTPPort)
+		}
+		if config.KubeConfig != "test" {
+			t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig)
+		}
+		if config.ListOutput != "yaml" {
+			t.Fatalf("Unexpected list_output value: %v", config.ListOutput)
+		}
+		if !config.ReadOnly {
+			t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly)
+		}
+		if config.DisableDestructive {
+			t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive)
+		}
 	})
 }
 
diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go
index 85b1861..13bd5c7 100644
--- a/pkg/kubernetes-mcp-server/cmd/root_test.go
+++ b/pkg/kubernetes-mcp-server/cmd/root_test.go
@@ -79,6 +79,54 @@ func TestConfig(t *testing.T) {
 			t.Fatalf("Expected error to be %s, got %s", expected, err.Error())
 		}
 	})
+	t.Run("set with valid --config", func(t *testing.T) {
+		ioStreams, out := testStream()
+		rootCmd := NewMCPServer(ioStreams)
+		_, file, _, _ := runtime.Caller(0)
+		validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
+		rootCmd.SetArgs([]string{"--version", "--config", validConfigPath})
+		_ = rootCmd.Execute()
+		expectedConfig := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
+		if m, err := regexp.MatchString(expectedConfig, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
+		}
+		expectedListOutput := `(?m)\" - ListOutput\: yaml"`
+		if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
+		}
+		expectedReadOnly := `(?m)\" - Read-only mode: true"`
+		if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
+		}
+		expectedDisableDestruction := `(?m)\" - Disable destructive tools: true"`
+		if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
+		}
+	})
+	t.Run("set with valid --config, flags override", func(t *testing.T) {
+		ioStreams, out := testStream()
+		rootCmd := NewMCPServer(ioStreams)
+		_, file, _, _ := runtime.Caller(0)
+		validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
+		rootCmd.SetArgs([]string{"--version", "--list-output=table", "--disable-destructive=false", "--read-only=false", "--config", validConfigPath})
+		_ = rootCmd.Execute()
+		expected := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
+		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
+		}
+		expectedListOutput := `(?m)\" - ListOutput\: table"`
+		if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
+		}
+		expectedReadOnly := `(?m)\" - Read-only mode: false"`
+		if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
+		}
+		expectedDisableDestruction := `(?m)\" - Disable destructive tools: false"`
+		if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
+			t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
+		}
+	})
 }
 
 func TestProfile(t *testing.T) {
diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml
new file mode 100644
index 0000000..bb8da16
--- /dev/null
+++ b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml
@@ -0,0 +1,15 @@
+log_level = 1
+sse_port = 9999
+kubeconfig = "test"
+list_output = "yaml"
+read_only = true
+disable_destructive = true
+
+[[denied_resources]]
+group = "apps"
+version = "v1"
+kind = "Deployment"
+
+[[denied_resources]]
+group = "rbac.authorization.k8s.io"
+version = "v1"
\ No newline at end of file