Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
1 change: 0 additions & 1 deletion internal/test/mock_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,3 @@ func (h *InOpenShiftHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
return
}
}

15 changes: 11 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func (s *ConfigSuite) TestReadConfigValid() {
list_output = "yaml"
read_only = true
disable_destructive = true
stateless = true

toolsets = ["core", "config", "helm", "metrics"]

Expand Down Expand Up @@ -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"} {
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)

Expand All @@ -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() {
Expand Down
61 changes: 61 additions & 0 deletions pkg/http/http_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
4 changes: 2 additions & 2 deletions pkg/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() })
Expand Down
7 changes: 7 additions & 0 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -86,6 +87,7 @@ type MCPServerOptions struct {
ListOutput string
ReadOnly bool
DisableDestructive bool
Stateless bool
RequireOAuth bool
OAuthAudience string
AuthorizationURL string
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 == "" {
Expand Down
46 changes: 44 additions & 2 deletions pkg/kubernetes-mcp-server/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
})
}

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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")
})
}

Expand Down
1 change: 1 addition & 0 deletions pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
9 changes: 7 additions & 2 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down