diff --git a/README.md b/README.md index b62630941..df0f1915a 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ uvx kubernetes-mcp-server@latest --help | `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") | | `--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. | +| `--stateless` | If set, the MCP server will run in stateless mode, disabling tool and prompt change notifications. This is useful for container deployments, load balancing, and serverless environments where maintaining client state is not desired. | | `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. | | `--disable-multi-cluster` | If set, the MCP server will disable multi-cluster support and will only use the current context from the kubeconfig file. This is useful if you want to restrict the MCP server to a single cluster. | diff --git a/internal/test/mock_server.go b/internal/test/mock_server.go index f94116725..ccdde056e 100644 --- a/internal/test/mock_server.go +++ b/internal/test/mock_server.go @@ -262,4 +262,3 @@ func (h *InOpenShiftHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) return } } - diff --git a/pkg/config/config.go b/pkg/config/config.go index 14af2f7ab..1eaee9759 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,10 +31,17 @@ type StaticConfig struct { // When true, expose only tools annotated with readOnlyHint=true ReadOnly bool `toml:"read_only,omitempty"` // When true, disable tools annotated with destructiveHint=true - DisableDestructive bool `toml:"disable_destructive,omitempty"` - Toolsets []string `toml:"toolsets,omitempty"` - EnabledTools []string `toml:"enabled_tools,omitempty"` - DisabledTools []string `toml:"disabled_tools,omitempty"` + DisableDestructive bool `toml:"disable_destructive,omitempty"` + // Stateless configures the MCP server to operate in stateless mode. + // When true, the server will not send notifications to clients (e.g., tools/list_changed, prompts/list_changed). + // This is useful for container deployments, load balancing, and serverless environments where + // maintaining client state is not desired or possible. However, this disables dynamic tool + // and prompt updates, requiring clients to manually refresh their tool/prompt lists. + // Defaults to false (stateful mode with notifications enabled). + Stateless bool `toml:"stateless,omitempty"` + Toolsets []string `toml:"toolsets,omitempty"` + EnabledTools []string `toml:"enabled_tools,omitempty"` + DisabledTools []string `toml:"disabled_tools,omitempty"` // Authorization-related fields // RequireOAuth indicates whether the server requires OAuth for authentication. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4116f008f..b35b26d8d 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -76,6 +76,7 @@ func (s *ConfigSuite) TestReadConfigValid() { list_output = "yaml" read_only = true disable_destructive = true + stateless = true toolsets = ["core", "config", "helm", "metrics"] @@ -116,6 +117,9 @@ func (s *ConfigSuite) TestReadConfigValid() { s.Run("disable_destructive parsed correctly", func() { s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive) }) + s.Run("stateless parsed correctly", func() { + s.Truef(config.Stateless, "Expected Stateless to be true, got %v", config.Stateless) + }) s.Run("toolsets", func() { s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets)) for _, toolset := range []string{"core", "config", "helm", "metrics"} { @@ -147,6 +151,39 @@ func (s *ConfigSuite) TestReadConfigValid() { }) } +func (s *ConfigSuite) TestReadConfigStatelessDefaults() { + // Test that stateless defaults to false when not specified + configPath := s.writeConfig(` + log_level = 1 + port = "8080" + `) + + config, err := Read(configPath, "") + s.Require().NoError(err) + s.Require().NotNil(config) + + s.Run("stateless defaults to false", func() { + s.Falsef(config.Stateless, "Expected Stateless to default to false, got %v", config.Stateless) + }) +} + +func (s *ConfigSuite) TestReadConfigStatelessExplicitFalse() { + // Test that stateless can be explicitly set to false + configPath := s.writeConfig(` + log_level = 1 + port = "8080" + stateless = false + `) + + config, err := Read(configPath, "") + s.Require().NoError(err) + s.Require().NotNil(config) + + s.Run("stateless explicit false", func() { + s.Falsef(config.Stateless, "Expected Stateless to be false, got %v", config.Stateless) + }) +} + func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() { if HasDefaultOverrides() { s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)") @@ -337,6 +374,7 @@ func (s *ConfigSuite) TestDropInConfigPartialOverride() { dropIn := filepath.Join(dropInDir, "10-partial.toml") err = os.WriteFile(dropIn, []byte(` read_only = true + stateless = true `), 0644) s.Require().NoError(err) @@ -346,6 +384,7 @@ func (s *ConfigSuite) TestDropInConfigPartialOverride() { s.Run("overrides specified field", func() { s.True(config.ReadOnly, "read_only should be overridden to true") + s.True(config.Stateless, "stateless should be overridden to true") }) s.Run("preserves all other fields", func() { diff --git a/pkg/http/http_mcp_test.go b/pkg/http/http_mcp_test.go index 0bd5cd9b3..8dfa7d283 100644 --- a/pkg/http/http_mcp_test.go +++ b/pkg/http/http_mcp_test.go @@ -62,6 +62,67 @@ func (s *McpTransportSuite) TestStreamableHttpTransport() { }) } +func (s *McpTransportSuite) TestStatelessConfiguration() { + s.Run("stateful mode by default", func() { + // Default configuration should be stateful (false) + s.False(s.StaticConfig.Stateless, "Expected default configuration to be stateful") + + // Test that the HTTP handler is created (we can't directly test the Stateless field + // of StreamableHTTPOptions as it's not exposed, but we can verify the server works) + httpClient, err := client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%s/mcp", s.StaticConfig.Port), transport.WithContinuousListening()) + s.Require().NoError(err, "Expected no error creating Streamable HTTP MCP client") + defer func() { _ = httpClient.Close() }() + + startErr := httpClient.Start(s.T().Context()) + s.Require().NoError(startErr, "Expected no error starting Streamable HTTP MCP client") + + _, initErr := httpClient.Initialize(s.T().Context(), test.McpInitRequest()) + s.Require().NoError(initErr, "Expected no error initializing MCP client in stateful mode") + }) +} + +type StatelessMcpTransportSuite struct { + BaseHttpSuite +} + +func (s *StatelessMcpTransportSuite) SetupTest() { + s.BaseHttpSuite.SetupTest() + // Configure for stateless mode + s.StaticConfig.Stateless = true + s.StartServer() +} + +func (s *StatelessMcpTransportSuite) TearDownTest() { + s.BaseHttpSuite.TearDownTest() +} + +func (s *StatelessMcpTransportSuite) TestStatelessMode() { + s.Run("stateless mode configuration", func() { + // Verify configuration is set to stateless + s.True(s.StaticConfig.Stateless, "Expected configuration to be stateless") + + // Test that the HTTP handler works in stateless mode + httpClient, err := client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%s/mcp", s.StaticConfig.Port), transport.WithContinuousListening()) + s.Require().NoError(err, "Expected no error creating Streamable HTTP MCP client") + defer func() { _ = httpClient.Close() }() + + startErr := httpClient.Start(s.T().Context()) + s.Require().NoError(startErr, "Expected no error starting Streamable HTTP MCP client") + + _, initErr := httpClient.Initialize(s.T().Context(), test.McpInitRequest()) + s.Require().NoError(initErr, "Expected no error initializing MCP client in stateless mode") + + // Basic functionality should still work + tools, err := httpClient.ListTools(s.T().Context(), mcp.ListToolsRequest{}) + s.Require().NoError(err, "Expected no error listing tools in stateless mode") + s.Greater(len(tools.Tools), 0, "Expected at least one tool in stateless mode") + }) +} + func TestMcpTransport(t *testing.T) { suite.Run(t, new(McpTransportSuite)) } + +func TestStatelessMcpTransport(t *testing.T) { + suite.Run(t, new(StatelessMcpTransportSuite)) +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 43b734427..6868029c6 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -241,7 +241,7 @@ func TestHealthCheck(t *testing.T) { }) }) // Health exposed even when require Authorization - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ClusterProviderStrategy: api.ClusterProviderKubeConfig}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ClusterProviderStrategy: api.ClusterProviderKubeConfig}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get health check endpoint with OAuth: %v", err) @@ -262,7 +262,7 @@ func TestWellKnownReverseProxy(t *testing.T) { ".well-known/openid-configuration", } // With No Authorization URL configured - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ClusterProviderStrategy: api.ClusterProviderKubeConfig}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ClusterProviderStrategy: api.ClusterProviderKubeConfig}}, func(ctx *httpContext) { for _, path := range cases { resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) t.Cleanup(func() { _ = resp.Body.Close() }) diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 87508fd28..05893804a 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -68,6 +68,7 @@ const ( flagListOutput = "list-output" flagReadOnly = "read-only" flagDisableDestructive = "disable-destructive" + flagStateless = "stateless" flagRequireOAuth = "require-oauth" flagOAuthAudience = "oauth-audience" flagAuthorizationURL = "authorization-url" @@ -86,6 +87,7 @@ type MCPServerOptions struct { ListOutput string ReadOnly bool DisableDestructive bool + Stateless bool RequireOAuth bool OAuthAudience string AuthorizationURL string @@ -140,6 +142,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.ListOutput, flagListOutput, o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to "+o.StaticConfig.ListOutput+".") cmd.Flags().BoolVar(&o.ReadOnly, flagReadOnly, o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed") cmd.Flags().BoolVar(&o.DisableDestructive, flagDisableDestructive, o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled") + cmd.Flags().BoolVar(&o.Stateless, flagStateless, o.Stateless, "If true, run the MCP server in stateless mode (disables tool/prompt change notifications). Useful for container deployments and load balancing. Default is false (stateful mode)") cmd.Flags().BoolVar(&o.RequireOAuth, flagRequireOAuth, o.RequireOAuth, "If true, requires OAuth authorization as defined in the Model Context Protocol (MCP) specification. This flag is ignored if transport type is stdio") _ = cmd.Flags().MarkHidden(flagRequireOAuth) cmd.Flags().StringVar(&o.OAuthAudience, flagOAuthAudience, o.OAuthAudience, "OAuth audience for token claims validation. Optional. If not set, the audience is not validated. Only valid if require-oauth is enabled.") @@ -198,6 +201,9 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableDestructive).Changed { m.StaticConfig.DisableDestructive = m.DisableDestructive } + if cmd.Flag(flagStateless).Changed { + m.StaticConfig.Stateless = m.Stateless + } if cmd.Flag(flagToolsets).Changed { m.StaticConfig.Toolsets = m.Toolsets } @@ -277,6 +283,7 @@ func (m *MCPServerOptions) Run() error { klog.V(1).Infof(" - ListOutput: %s", m.StaticConfig.ListOutput) klog.V(1).Infof(" - Read-only mode: %t", m.StaticConfig.ReadOnly) klog.V(1).Infof(" - Disable destructive tools: %t", m.StaticConfig.DisableDestructive) + klog.V(1).Infof(" - Stateless mode: %t", m.StaticConfig.Stateless) strategy := m.StaticConfig.ClusterProviderStrategy if strategy == "" { diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 07c07dce8..18a77e589 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -105,13 +105,17 @@ func TestConfig(t *testing.T) { 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) } + expectedStateless := `(?m)\" - Stateless mode: true"` + if m, err := regexp.MatchString(expectedStateless, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedStateless, out.String(), err) + } }) t.Run("set with valid --config, flags take precedence", 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.SetArgs([]string{"--version", "--list-output=table", "--disable-destructive=false", "--read-only=false", "--stateless=false", "--config", validConfigPath}) _ = rootCmd.Execute() expected := `(?m)\" - Config\:[^\"]+valid-config\.toml\"` if m, err := regexp.MatchString(expected, out.String()); !m || err != nil { @@ -129,6 +133,40 @@ func TestConfig(t *testing.T) { 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) } + expectedStateless := `(?m)\" - Stateless mode: false"` + if m, err := regexp.MatchString(expectedStateless, out.String()); !m || err != nil { + t.Fatalf("Expected stateless mode to be false (flag overrides config), got %s %v", out.String(), err) + } + }) + t.Run("stateless flag defaults to false", func(t *testing.T) { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"}) + _ = rootCmd.Execute() + expectedStateless := `(?m)\" - Stateless mode: false"` + if m, err := regexp.MatchString(expectedStateless, out.String()); !m || err != nil { + t.Fatalf("Expected stateless mode to be false by default, got %s %v", out.String(), err) + } + }) + t.Run("stateless flag set to true", func(t *testing.T) { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--stateless=true"}) + _ = rootCmd.Execute() + expectedStateless := `(?m)\" - Stateless mode: true"` + if m, err := regexp.MatchString(expectedStateless, out.String()); !m || err != nil { + t.Fatalf("Expected stateless mode to be true, got %s %v", out.String(), err) + } + }) + t.Run("stateless flag set to false explicitly", func(t *testing.T) { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--stateless=false"}) + _ = rootCmd.Execute() + expectedStateless := `(?m)\" - Stateless mode: false"` + if m, err := regexp.MatchString(expectedStateless, out.String()); !m || err != nil { + t.Fatalf("Expected stateless mode to be false, got %s %v", out.String(), err) + } }) } @@ -192,6 +230,7 @@ func (s *CmdSuite) TestConfigDir() { s.Require().NoError(os.WriteFile(filepath.Join(dropInDir, "10-override.toml"), []byte(` read_only = true disable_destructive = true + stateless = true `), 0644)) ioStreams, out := testStream() @@ -201,6 +240,7 @@ func (s *CmdSuite) TestConfigDir() { s.Contains(out.String(), "ListOutput: table", "list_output from main config") s.Contains(out.String(), "Read-only mode: true", "read_only overridden by drop-in") s.Contains(out.String(), "Disable destructive tools: true", "disable_destructive from drop-in") + s.Contains(out.String(), "Stateless mode: true", "stateless from drop-in") }) s.Run("multiple drop-in files are merged in order", func() { dropInDir := s.T().TempDir() @@ -227,15 +267,17 @@ func (s *CmdSuite) TestConfigDir() { list_output = "yaml" read_only = true disable_destructive = true + stateless = true `), 0644)) ioStreams, out := testStream() rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output=table", "--read-only=false", "--disable-destructive=false", "--config-dir", dropInDir}) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output=table", "--read-only=false", "--disable-destructive=false", "--stateless=false", "--config-dir", dropInDir}) s.Require().NoError(rootCmd.Execute()) s.Contains(out.String(), "ListOutput: table", "flag takes precedence") s.Contains(out.String(), "Read-only mode: false", "flag takes precedence") s.Contains(out.String(), "Disable destructive tools: false", "flag takes precedence") + s.Contains(out.String(), "Stateless mode: false", "flag takes precedence") }) } diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml index 1b8afc61b..0cd9c5603 100644 --- a/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml +++ b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml @@ -4,6 +4,7 @@ kubeconfig = "test" list_output = "yaml" read_only = true disable_destructive = true +stateless = true denied_resources = [ {group = "apps", version = "v1", kind = "Deployment"}, diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 889601c2c..ef262b369 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -264,9 +264,14 @@ func (s *Server) ServeHTTP() *mcp.StreamableHTTPHandler { return mcp.NewStreamableHTTPHandler(func(request *http.Request) *mcp.Server { return s.server }, &mcp.StreamableHTTPOptions{ - // For clients to be able to listen to tool changes, we need to set the server stateful + // Stateless mode configuration from server settings. + // When Stateless is true, the server will not send notifications to clients + // (e.g., tools/list_changed, prompts/list_changed). This disables dynamic + // tool and prompt updates but is useful for container deployments, load + // balancing, and serverless environments where maintaining client state + // is not desired or possible. // https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server - Stateless: false, + Stateless: s.configuration.Stateless, }) }