diff --git a/docs/TOOLSET_VERSIONING.md b/docs/TOOLSET_VERSIONING.md new file mode 100644 index 000000000..221033589 --- /dev/null +++ b/docs/TOOLSET_VERSIONING.md @@ -0,0 +1,53 @@ +# Toolset Versioning + +This document describes how toolsets are versioned, the rules for toolsets changing versions, and how to configure which +tools/toolsets should be used through their versions. + +## How Toolsets and Tools are Versioned + +All tools/prompts and toolsets are versioned as one of "alpha", "beta", or "ga"/"stable". Each toolset has a default version +for the toolset, however individual tools/prompts may have their own versions. For example, a toolset as a whole may be in beta, +however a newly added tool in that toolset may only be in alpha. + +The general idea for these versions is: +- "alpha": the toolset is not guaranteed to work well +- "beta": the toolset is not guaranteed to work well, but we are evaluating how well it works +- "stable": the toolset works well, and we are evaluating how well it works to avoid regressions + +## Rules for Tool/Prompt/Toolset Versioning + +Below are the criteria for the versioning of every tool/prompt/toolset. + +### Alpha + +All tools/prompts/toolsets begin in "alpha". If you are contributing a new tool/prompt/toolset, this is the version to set. +There are no minimum requirements for something to be considered alpha, apart from the code getting merged. + +### Beta + +For a tool/prompt/toolset to enter into "beta", we require that there are eval scenarios. For a toolset to enter "beta", there must be scenarios +excercising all of the tools and prompts in the toolset. For individual tools and prompts to enter "beta", we only require an eval scenario +for the specific tool or prompt. + +**Note**: for beta we do not require that all the eval scenarios are passing - we just require that they exist. + +### GA/Stable + +For a tool/prompt/toolset to enter into "stable", we require that 95% or more of the eval scenarios are passing. There is the same requirements as "beta" in terms of the number of evaluation scenarios. + +## Configuring tools/toolsets on the server by their version + +When configuring the MCP server, you can set a default toolset version to use for all tools with the `default_toolset_version` key. +Within all the toolsets you enable, only the tools which meet this minimum version will be enabled. For example, if a toolset has +both "alpha" and "beta" tools and you enable only "beta" tools on the toolset, you will not see any of the "alpha" tools. + +You can also enable specific minimum versions for specific toolsets using the "toolset:version" syntax when enabling the toolset. +For example, if you want to allow all the "alpha" tools in the "core" toolset, you could set `toolsets = [ "core:alpha" ]`, and this would +enable all alpha+ tools in the core toolset. + +See a full config example below: +```toml +default_toolset_version = "beta" + +toolsets = [ "core", "config", "helm:alpha" ] +``` diff --git a/pkg/api/prompts.go b/pkg/api/prompts.go index 62e6f9f14..b6b2553ea 100644 --- a/pkg/api/prompts.go +++ b/pkg/api/prompts.go @@ -9,6 +9,7 @@ type ServerPrompt struct { Handler PromptHandlerFunc ClusterAware *bool ArgumentSchema map[string]PromptArgument + Version *Version // Optional version - defaults to toolset version if not set } // IsClusterAware indicates whether the prompt can accept a "cluster" or "context" parameter diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 59b1f3c70..0df4e02f6 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -3,16 +3,46 @@ package api import ( "context" "encoding/json" + "fmt" + "strings" "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/google/jsonschema-go/jsonschema" ) +type Version int + +const ( + VersionUnknown Version = iota + VersionAlpha + VersionBeta + VersionGA +) + +func (v *Version) UnmarshalText(text []byte) error { + var tmp Version + switch strings.ToLower(string(text)) { + case "alpha": + tmp = VersionAlpha + case "beta": + tmp = VersionBeta + case "ga", "", "stable": + tmp = VersionGA + default: + return fmt.Errorf("unknown version '%s': must be one of 'alpha', 'beta', 'stable', 'ga'", text) + } + + *v = tmp + + return nil +} + type ServerTool struct { Tool Tool Handler ToolHandlerFunc ClusterAware *bool TargetListProvider *bool + Version *Version // Optional version - defaults to toolset version if not set } // IsClusterAware indicates whether the tool can accept a "cluster" or "context" parameter @@ -46,6 +76,9 @@ type Toolset interface { // GetPrompts returns the prompts provided by this toolset. // Returns nil if the toolset doesn't provide any prompts. GetPrompts() []ServerPrompt + // GetVersion returns the version of the toolset. + // This version can be overridden by specific tools/prompts (e.g. a toolset may be beta, but have an alpha tool). + GetVersion() Version } type ToolCallRequest interface { diff --git a/pkg/config/config.go b/pkg/config/config.go index e40b87909..c9dd16444 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -86,6 +86,9 @@ type StaticConfig struct { // This can be used to provide specific instructions on how the client should use the server ServerInstructions string `toml:"server_instructions,omitempty"` + // Which toolset version to enable (any tools/toolsets below this will be disabled) + DefaultToolsetVersion api.Version `toml:"default_toolset_version"` + // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]api.ExtendedConfig // Internal: parsed toolset configs (not exposed to TOML package) diff --git a/pkg/config/config_default.go b/pkg/config/config_default.go index febea70cf..64fabfcab 100644 --- a/pkg/config/config_default.go +++ b/pkg/config/config_default.go @@ -4,12 +4,14 @@ import ( "bytes" "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/pkg/api" ) func Default() *StaticConfig { defaultConfig := StaticConfig{ - ListOutput: "table", - Toolsets: []string{"core", "config", "helm"}, + ListOutput: "table", + Toolsets: []string{"core", "config"}, + DefaultToolsetVersion: api.VersionBeta, // TODO: once the core toolset moves to GA, switch this to GA } overrides := defaultOverrides() mergedConfig := mergeConfig(defaultConfig, overrides) diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index e6d947b1c..16cb04136 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -57,43 +57,45 @@ kubernetes-mcp-server --port 8080 --disable-multi-cluster ) const ( - flagVersion = "version" - flagLogLevel = "log-level" - flagConfig = "config" - flagConfigDir = "config-dir" - flagPort = "port" - flagSSEBaseUrl = "sse-base-url" - flagKubeconfig = "kubeconfig" - flagToolsets = "toolsets" - flagListOutput = "list-output" - flagReadOnly = "read-only" - flagDisableDestructive = "disable-destructive" - flagStateless = "stateless" - flagRequireOAuth = "require-oauth" - flagOAuthAudience = "oauth-audience" - flagAuthorizationURL = "authorization-url" - flagServerUrl = "server-url" - flagCertificateAuthority = "certificate-authority" - flagDisableMultiCluster = "disable-multi-cluster" + flagVersion = "version" + flagLogLevel = "log-level" + flagConfig = "config" + flagConfigDir = "config-dir" + flagPort = "port" + flagSSEBaseUrl = "sse-base-url" + flagKubeconfig = "kubeconfig" + flagToolsets = "toolsets" + flagListOutput = "list-output" + flagReadOnly = "read-only" + flagDisableDestructive = "disable-destructive" + flagStateless = "stateless" + flagRequireOAuth = "require-oauth" + flagOAuthAudience = "oauth-audience" + flagAuthorizationURL = "authorization-url" + flagServerUrl = "server-url" + flagCertificateAuthority = "certificate-authority" + flagDisableMultiCluster = "disable-multi-cluster" + flagDefaultToolsetVersion = "default-toolset-version" ) type MCPServerOptions struct { - Version bool - LogLevel int - Port string - SSEBaseUrl string - Kubeconfig string - Toolsets []string - ListOutput string - ReadOnly bool - DisableDestructive bool - Stateless bool - RequireOAuth bool - OAuthAudience string - AuthorizationURL string - CertificateAuthority string - ServerURL string - DisableMultiCluster bool + Version bool + LogLevel int + Port string + SSEBaseUrl string + Kubeconfig string + Toolsets []string + ListOutput string + ReadOnly bool + DisableDestructive bool + Stateless bool + RequireOAuth bool + OAuthAudience string + AuthorizationURL string + CertificateAuthority string + ServerURL string + DisableMultiCluster bool + DefaultToolsetVersion string ConfigPath string ConfigDir string @@ -154,6 +156,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.CertificateAuthority, flagCertificateAuthority, o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden(flagCertificateAuthority) cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.") + cmd.Flags().StringVar(&o.DefaultToolsetVersion, flagDefaultToolsetVersion, o.DefaultToolsetVersion, "Default version to enable for tools/toolsets, within the enabled tools and toolsets.") return cmd } @@ -225,6 +228,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster { m.StaticConfig.ClusterProviderStrategy = api.ClusterProviderDisabled } + if cmd.Flag(flagDefaultToolsetVersion).Changed { + var v api.Version + if err := v.UnmarshalText([]byte(m.DefaultToolsetVersion)); err == nil { + m.StaticConfig.DefaultToolsetVersion = v + } + } } func (m *MCPServerOptions) initializeLogging() { diff --git a/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go b/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go index b605fa370..13c706a0e 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_sighup_test.go @@ -81,9 +81,9 @@ func (s *SIGHUPSuite) TestSIGHUPReloadsConfigFromFile() { s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list")) }) - // Modify the config file to add helm toolset + // Modify the config file to add helm toolset (with alpha version to include alpha-versioned tools) s.Require().NoError(os.WriteFile(configPath, []byte(` - toolsets = ["core", "config", "helm"] + toolsets = ["core", "config", "helm:alpha"] `), 0644)) // Send SIGHUP to current process @@ -97,10 +97,10 @@ func (s *SIGHUPSuite) TestSIGHUPReloadsConfigFromFile() { } func (s *SIGHUPSuite) TestSIGHUPReloadsFromDropInDirectory() { - // Create initial config file - with helm enabled + // Create initial config file - with helm enabled (with alpha version to include alpha-versioned tools) configPath := filepath.Join(s.tempDir, "config.toml") s.Require().NoError(os.WriteFile(configPath, []byte(` - toolsets = ["core", "config", "helm"] + toolsets = ["core", "config", "helm:alpha"] `), 0644)) // Create initial drop-in file that removes helm @@ -115,9 +115,9 @@ func (s *SIGHUPSuite) TestSIGHUPReloadsFromDropInDirectory() { s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list")) }) - // Update drop-in file to add helm back + // Update drop-in file to add helm back (with alpha version to include alpha-versioned tools) s.Require().NoError(os.WriteFile(dropInPath, []byte(` - toolsets = ["core", "config", "helm"] + toolsets = ["core", "config", "helm:alpha"] `), 0644)) // Send SIGHUP @@ -161,9 +161,9 @@ func (s *SIGHUPSuite) TestSIGHUPWithInvalidConfigContinues() { s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list")) }) - // Now fix the config and add helm + // Now fix the config and add helm (with alpha version to include alpha-versioned tools) s.Require().NoError(os.WriteFile(configPath, []byte(` - toolsets = ["core", "config", "helm"] + toolsets = ["core", "config", "helm:alpha"] `), 0644)) // Send another SIGHUP @@ -189,9 +189,9 @@ func (s *SIGHUPSuite) TestSIGHUPWithConfigDirOnly() { s.False(slices.Contains(s.server.GetEnabledTools(), "helm_list")) }) - // Update drop-in file to add helm + // Update drop-in file to add helm (with alpha version to include alpha-versioned tools) s.Require().NoError(os.WriteFile(dropInPath, []byte(` - toolsets = ["core", "config", "helm"] + toolsets = ["core", "config", "helm:alpha"] `), 0644)) // Send SIGHUP diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 12efda92b..8184d7dd8 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -299,8 +299,8 @@ func TestToolsets(t *testing.T) { ioStreams, out := testStream() rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"}) - if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") { - t.Fatalf("Expected toolsets 'full', got %s %v", out, err) + if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config") { + t.Fatalf("Expected toolsets 'core, config', got %s %v", out, err) } }) t.Run("set with --toolsets", func(t *testing.T) { diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index 2646118d0..05fe32f76 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -31,6 +31,9 @@ type HelmSuite struct { func (s *HelmSuite) SetupTest() { s.BaseMcpSuite.SetupTest() + s.Require().NoError(toml.Unmarshal([]byte(` + toolsets = [ "helm:alpha" ] + `), s.Cfg), "Expected to parse toolsets config") clearHelmReleases(s.T().Context(), kubernetes.NewForConfigOrDie(envTestRestConfig)) // Capture log output to verify denied resource messages diff --git a/pkg/mcp/kiali_test.go b/pkg/mcp/kiali_test.go index 2d87b204d..db1eb0a12 100644 --- a/pkg/mcp/kiali_test.go +++ b/pkg/mcp/kiali_test.go @@ -25,7 +25,7 @@ func (s *KialiSuite) SetupTest() { s.mockServer.Config().BearerToken = "token-xyz" kubeConfig := s.Cfg.KubeConfig s.Cfg = test.Must(config.ReadToml([]byte(fmt.Sprintf(` - toolsets = ["kiali"] + toolsets = ["kiali:alpha"] [toolset_configs.kiali] url = "%s" `, s.mockServer.Config().Host)))) diff --git a/pkg/mcp/kubevirt_test.go b/pkg/mcp/kubevirt_test.go index aa2514224..d28cb948b 100644 --- a/pkg/mcp/kubevirt_test.go +++ b/pkg/mcp/kubevirt_test.go @@ -59,7 +59,7 @@ func (s *KubevirtSuite) TearDownSuite() { func (s *KubevirtSuite) SetupTest() { s.BaseMcpSuite.SetupTest() s.Require().NoError(toml.Unmarshal([]byte(` - toolsets = [ "kubevirt" ] + toolsets = [ "kubevirt:alpha" ] `), s.Cfg), "Expected to parse toolsets config") s.InitMcpClient() } diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 6620b124b..e84557d2a 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -30,7 +30,10 @@ type Configuration struct { func (c *Configuration) Toolsets() []api.Toolset { if c.toolsets == nil { for _, toolset := range c.StaticConfig.Toolsets { - c.toolsets = append(c.toolsets, toolsets.ToolsetFromString(toolset)) + versioned := toolsets.VersionedToolsetFromString(toolset, c.DefaultToolsetVersion) + if versioned != nil { + c.toolsets = append(c.toolsets, versioned) + } } } return c.toolsets diff --git a/pkg/mcp/mcp_reload_test.go b/pkg/mcp/mcp_reload_test.go index 597299a06..24ed11082 100644 --- a/pkg/mcp/mcp_reload_test.go +++ b/pkg/mcp/mcp_reload_test.go @@ -66,7 +66,7 @@ func (s *ConfigReloadSuite) TestConfigurationReload() { newConfig := config.Default() newConfig.LogLevel = 5 newConfig.ListOutput = "yaml" - newConfig.Toolsets = []string{"core", "config", "helm"} + newConfig.Toolsets = []string{"core", "config", "helm:alpha"} newConfig.KubeConfig = s.Cfg.KubeConfig err = server.ReloadConfiguration(newConfig) @@ -74,14 +74,14 @@ func (s *ConfigReloadSuite) TestConfigurationReload() { s.Equal(5, server.configuration.LogLevel) s.Equal("yaml", server.configuration.StaticConfig.ListOutput) - s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets) + s.Equal([]string{"core", "config", "helm:alpha"}, server.configuration.StaticConfig.Toolsets) }) s.Run("reload with partial changes", func() { newConfig := config.Default() newConfig.LogLevel = 7 newConfig.ListOutput = "yaml" - newConfig.Toolsets = []string{"core", "config", "helm"} + newConfig.Toolsets = []string{"core", "config", "helm:alpha"} newConfig.KubeConfig = s.Cfg.KubeConfig err = server.ReloadConfiguration(newConfig) @@ -89,7 +89,7 @@ func (s *ConfigReloadSuite) TestConfigurationReload() { s.Equal(7, server.configuration.LogLevel) s.Equal("yaml", server.configuration.StaticConfig.ListOutput) - s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets) + s.Equal([]string{"core", "config", "helm:alpha"}, server.configuration.StaticConfig.Toolsets) }) s.Run("reload back to defaults", func() { @@ -122,7 +122,7 @@ func (s *ConfigReloadSuite) TestConfigurationValues() { newConfig := config.Default() newConfig.LogLevel = 9 newConfig.ListOutput = "yaml" - newConfig.Toolsets = []string{"core", "config", "helm"} + newConfig.Toolsets = []string{"core", "config", "helm:alpha"} newConfig.KubeConfig = s.Cfg.KubeConfig err = server.ReloadConfiguration(newConfig) @@ -131,7 +131,7 @@ func (s *ConfigReloadSuite) TestConfigurationValues() { // Verify configuration was updated s.NotEqual(initialLogLevel, server.configuration.LogLevel) s.Equal(9, server.configuration.LogLevel) - s.Equal([]string{"core", "config", "helm"}, server.configuration.StaticConfig.Toolsets) + s.Equal([]string{"core", "config", "helm:alpha"}, server.configuration.StaticConfig.Toolsets) s.Equal("yaml", server.configuration.StaticConfig.ListOutput) }) } @@ -166,7 +166,7 @@ func (s *ConfigReloadSuite) TestMultipleReloads() { cfg3 := config.Default() cfg3.LogLevel = 9 cfg3.KubeConfig = s.Cfg.KubeConfig - cfg3.Toolsets = []string{"core", "config", "helm"} + cfg3.Toolsets = []string{"core", "config", "helm:alpha"} err = server.ReloadConfiguration(cfg3) s.Require().NoError(err) s.Equal(9, server.configuration.LogLevel) @@ -174,28 +174,29 @@ func (s *ConfigReloadSuite) TestMultipleReloads() { } func (s *ConfigReloadSuite) TestReloadUpdatesToolsets() { - server, err := NewServer(Configuration{ - StaticConfig: s.Cfg, - }, nil, nil) - s.Require().NoError(err) - s.server = server + // Initialize MCP client which creates s.mcpServer + s.InitMcpClient() // Get initial tools - s.InitMcpClient() initialTools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) s.Require().NoError(err) s.Require().Greater(len(initialTools.Tools), 0) - // Add helm toolset via reload + // Verify helm tools are NOT present initially + for _, tool := range initialTools.Tools { + s.NotEqual("helm_list", tool.Name, "helm tools should not be present initially") + } + + // Add helm toolset via reload (helm is alpha, so we need to specify helm:alpha) newConfig := config.Default() - newConfig.Toolsets = []string{"core", "config", "helm"} + newConfig.Toolsets = []string{"core", "config", "helm:alpha"} newConfig.KubeConfig = s.Cfg.KubeConfig - // Reload configuration - err = server.ReloadConfiguration(newConfig) + // Reload configuration on the same server used by the MCP client + err = s.mcpServer.ReloadConfiguration(newConfig) s.Require().NoError(err) - // Verify helm tools are available + // Verify helm tools are available after reload reloadedTools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) s.Require().NoError(err) diff --git a/pkg/mcp/mcp_toolset_prompts_test.go b/pkg/mcp/mcp_toolset_prompts_test.go index 3256f882f..f4ec7599d 100644 --- a/pkg/mcp/mcp_toolset_prompts_test.go +++ b/pkg/mcp/mcp_toolset_prompts_test.go @@ -35,6 +35,7 @@ func (s *McpToolsetPromptsSuite) TestToolsetReturningPrompts() { testToolset := &mockToolsetWithPrompts{ name: "test-toolset", description: "Test toolset with prompts", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -116,6 +117,7 @@ func (s *McpToolsetPromptsSuite) TestToolsetReturningNilPrompts() { testToolset := &mockToolsetWithPrompts{ name: "empty-toolset", description: "Toolset with no prompts", + version: api.VersionGA, prompts: nil, } @@ -142,6 +144,7 @@ func (s *McpToolsetPromptsSuite) TestToolsetReturningEmptyPrompts() { testToolset := &mockToolsetWithPrompts{ name: "empty-slice-toolset", description: "Toolset with empty prompts slice", + version: api.VersionGA, prompts: []api.ServerPrompt{}, } @@ -168,6 +171,7 @@ func (s *McpToolsetPromptsSuite) TestMultipleToolsetsPromptCollection() { toolset1 := &mockToolsetWithPrompts{ name: "toolset1", description: "First toolset", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -184,6 +188,7 @@ func (s *McpToolsetPromptsSuite) TestMultipleToolsetsPromptCollection() { toolset2 := &mockToolsetWithPrompts{ name: "toolset2", description: "Second toolset", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -227,6 +232,7 @@ func (s *McpToolsetPromptsSuite) TestConfigPromptsOverrideToolsetPrompts() { testToolset := &mockToolsetWithPrompts{ name: "test-toolset", description: "Test toolset", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -305,6 +311,7 @@ func (s *McpToolsetPromptsSuite) TestPromptsNotExposedWhenToolsetDisabled() { enabledToolset := &mockToolsetWithPrompts{ name: "enabled-toolset", description: "Enabled toolset", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -321,6 +328,7 @@ func (s *McpToolsetPromptsSuite) TestPromptsNotExposedWhenToolsetDisabled() { disabledToolset := &mockToolsetWithPrompts{ name: "disabled-toolset", description: "Disabled toolset", + version: api.VersionGA, prompts: []api.ServerPrompt{ { Prompt: api.Prompt{ @@ -367,9 +375,12 @@ func (s *McpToolsetPromptsSuite) TestPromptsNotExposedWhenToolsetDisabled() { type mockToolsetWithPrompts struct { name string description string + version api.Version prompts []api.ServerPrompt } +var _ api.Toolset = &mockToolsetWithPrompts{} + func (m *mockToolsetWithPrompts) GetName() string { return m.name } @@ -378,6 +389,10 @@ func (m *mockToolsetWithPrompts) GetDescription() string { return m.description } +func (m *mockToolsetWithPrompts) GetVersion() api.Version { + return m.version +} + func (m *mockToolsetWithPrompts) GetTools(_ api.Openshift) []api.ServerTool { return nil } diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index 68ff4b179..bad17c606 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -38,6 +38,7 @@ func (s *ToolsetsSuite) SetupTest() { s.originalToolsets = toolsets.Toolsets() s.MockServer = test.NewMockServer() s.Cfg = configuration.Default() + s.Cfg.DefaultToolsetVersion = api.VersionAlpha // include all toolsets so that we get better snapshot coverage s.Cfg.KubeConfig = s.KubeconfigFile(s.T()) s.updateJson = os.Getenv(updateJsonEnvVar) != "" } diff --git a/pkg/toolsets/config/toolset.go b/pkg/toolsets/config/toolset.go index 3d08fb597..770fd8b3e 100644 --- a/pkg/toolsets/config/toolset.go +++ b/pkg/toolsets/config/toolset.go @@ -19,6 +19,10 @@ func (t *Toolset) GetDescription() string { return "View and manage the current local Kubernetes configuration (kubeconfig)" } +func (t *Toolset) GetVersion() api.Version { + return api.VersionBeta +} + func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( initConfiguration(), diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index 536b9428c..80efa8ed2 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -19,6 +19,10 @@ func (t *Toolset) GetDescription() string { return "Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.)" } +func (t *Toolset) GetVersion() api.Version { + return api.VersionBeta +} + func (t *Toolset) GetTools(o api.Openshift) []api.ServerTool { return slices.Concat( initEvents(), diff --git a/pkg/toolsets/helm/toolset.go b/pkg/toolsets/helm/toolset.go index 6bdbfd419..42af1e38f 100644 --- a/pkg/toolsets/helm/toolset.go +++ b/pkg/toolsets/helm/toolset.go @@ -19,6 +19,10 @@ func (t *Toolset) GetDescription() string { return "Tools for managing Helm charts and releases" } +func (t *Toolset) GetVersion() api.Version { + return api.VersionAlpha +} + func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( initHelm(), diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go index 6c36800bf..54bfc37ca 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -21,6 +21,10 @@ func (t *Toolset) GetDescription() string { return defaults.ToolsetDescription() } +func (t *Toolset) GetVersion() api.Version { + return api.VersionAlpha +} + func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( kialiTools.InitGetMeshGraph(), diff --git a/pkg/toolsets/kubevirt/toolset.go b/pkg/toolsets/kubevirt/toolset.go index 9c87ddafd..c0deb81a0 100644 --- a/pkg/toolsets/kubevirt/toolset.go +++ b/pkg/toolsets/kubevirt/toolset.go @@ -21,6 +21,10 @@ func (t *Toolset) GetDescription() string { return "KubeVirt virtual machine management tools" } +func (t *Toolset) GetVersion() api.Version { + return api.VersionAlpha +} + func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( vm_create.Tools(), diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 031bed437..7d8a3e4ac 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -43,7 +43,7 @@ func ToolsetFromString(name string) api.Toolset { func Validate(toolsets []string) error { for _, toolset := range toolsets { - if ToolsetFromString(toolset) == nil { + if ToolsetFromString(toolset) == nil && VersionedToolsetFromString(toolset, api.VersionUnknown) == nil { return fmt.Errorf("invalid toolset name: %s, valid names are: %s", toolset, strings.Join(ToolsetNames(), ", ")) } } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index c2e869814..41ab5e168 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -26,12 +26,15 @@ func (s *ToolsetsSuite) TearDownTest() { type TestToolset struct { name string description string + version api.Version } func (t *TestToolset) GetName() string { return t.name } func (t *TestToolset) GetDescription() string { return t.description } +func (t *TestToolset) GetVersion() api.Version { return t.version } + func (t *TestToolset) GetTools(_ api.Openshift) []api.ServerTool { return nil } func (t *TestToolset) GetPrompts() []api.ServerPrompt { return nil } diff --git a/pkg/toolsets/versioned_toolsets.go b/pkg/toolsets/versioned_toolsets.go new file mode 100644 index 000000000..63053d869 --- /dev/null +++ b/pkg/toolsets/versioned_toolsets.go @@ -0,0 +1,87 @@ +package toolsets + +import ( + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +type VersionedToolset struct { + Toolset api.Toolset + MinVersion api.Version +} + +func VersionedToolsetFromString(name string, defaultMinVersion api.Version) *VersionedToolset { + parts := strings.SplitN(strings.TrimSpace(name), ":", 2) + toolsetName := parts[0] + + for _, toolset := range Toolsets() { + if toolset.GetName() == toolsetName { + result := &VersionedToolset{Toolset: toolset, MinVersion: defaultMinVersion} + if len(parts) == 2 { + var version api.Version + if err := version.UnmarshalText([]byte(parts[1])); err == nil { + result.MinVersion = version + } + } + return result + } + } + + return nil +} + +var _ api.Toolset = &VersionedToolset{} + +func (v *VersionedToolset) GetName() string { + return v.Toolset.GetName() +} + +func (v *VersionedToolset) GetDescription() string { + return v.Toolset.GetDescription() +} + +func (v *VersionedToolset) GetTools(o api.Openshift) []api.ServerTool { + defaultVersion := v.Toolset.GetVersion() + + allTools := v.Toolset.GetTools(o) + tools := make([]api.ServerTool, 0, len(allTools)) + + for _, t := range allTools { + version := defaultVersion + if t.Version != nil { + version = *t.Version + } + + if version >= v.MinVersion { + tools = append(tools, t) + } + } + + return tools + +} + +func (v *VersionedToolset) GetPrompts() []api.ServerPrompt { + defaultVersion := v.Toolset.GetVersion() + + allPrompts := v.Toolset.GetPrompts() + prompts := make([]api.ServerPrompt, 0, len(allPrompts)) + + for _, p := range allPrompts { + version := defaultVersion + if p.Version != nil { + version = *p.Version + } + + if version >= v.MinVersion { + prompts = append(prompts, p) + } + } + + return prompts +} + +func (v *VersionedToolset) GetVersion() api.Version { + return v.Toolset.GetVersion() +} diff --git a/pkg/toolsets/versioned_toolsets_test.go b/pkg/toolsets/versioned_toolsets_test.go new file mode 100644 index 000000000..71ecc85d8 --- /dev/null +++ b/pkg/toolsets/versioned_toolsets_test.go @@ -0,0 +1,447 @@ +package toolsets + +import ( + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/suite" + "k8s.io/utils/ptr" +) + +type VersionedToolsetsSuite struct { + suite.Suite + originalToolsets []api.Toolset +} + +func (s *VersionedToolsetsSuite) SetupTest() { + s.originalToolsets = Toolsets() + Clear() +} + +func (s *VersionedToolsetsSuite) TearDownTest() { + Clear() + for _, toolset := range s.originalToolsets { + Register(toolset) + } +} + +// MockToolset is a configurable toolset for testing version filtering +type MockToolset struct { + name string + description string + version api.Version + tools []api.ServerTool + prompts []api.ServerPrompt +} + +func (t *MockToolset) GetName() string { return t.name } +func (t *MockToolset) GetDescription() string { return t.description } +func (t *MockToolset) GetVersion() api.Version { return t.version } +func (t *MockToolset) GetTools(_ api.Openshift) []api.ServerTool { return t.tools } +func (t *MockToolset) GetPrompts() []api.ServerPrompt { return t.prompts } + +var _ api.Toolset = (*MockToolset)(nil) + +func (s *VersionedToolsetsSuite) TestVersionedToolsetFromString() { + s.Run("returns nil for non-existent toolset", func() { + result := VersionedToolsetFromString("non-existent", api.VersionGA) + s.Nil(result) + }) + + s.Run("returns versioned toolset for existing toolset without version suffix", func() { + Register(&MockToolset{name: "core", version: api.VersionGA}) + result := VersionedToolsetFromString("core", api.VersionBeta) + s.NotNil(result) + s.Equal("core", result.GetName()) + s.Equal(api.VersionBeta, result.MinVersion) + }) + + s.Run("parses toolset:version format correctly", func() { + Register(&MockToolset{name: "helm", version: api.VersionGA}) + + s.Run("alpha version", func() { + result := VersionedToolsetFromString("helm:alpha", api.VersionGA) + s.NotNil(result) + s.Equal("helm", result.GetName()) + s.Equal(api.VersionAlpha, result.MinVersion) + }) + + s.Run("beta version", func() { + result := VersionedToolsetFromString("helm:beta", api.VersionGA) + s.NotNil(result) + s.Equal(api.VersionBeta, result.MinVersion) + }) + + s.Run("ga version", func() { + result := VersionedToolsetFromString("helm:ga", api.VersionAlpha) + s.NotNil(result) + s.Equal(api.VersionGA, result.MinVersion) + }) + + s.Run("stable version (alias for ga)", func() { + result := VersionedToolsetFromString("helm:stable", api.VersionAlpha) + s.NotNil(result) + s.Equal(api.VersionGA, result.MinVersion) + }) + }) + + s.Run("falls back to default version for invalid version string", func() { + Register(&MockToolset{name: "metrics", version: api.VersionGA}) + result := VersionedToolsetFromString("metrics:invalid", api.VersionBeta) + s.NotNil(result) + s.Equal("metrics", result.GetName()) + s.Equal(api.VersionBeta, result.MinVersion) + }) + + s.Run("trims whitespace from input", func() { + Register(&MockToolset{name: "config", version: api.VersionGA}) + result := VersionedToolsetFromString(" config:alpha ", api.VersionGA) + s.NotNil(result) + s.Equal("config", result.GetName()) + s.Equal(api.VersionAlpha, result.MinVersion) + }) + + s.Run("handles case-insensitive version strings", func() { + Register(&MockToolset{name: "test", version: api.VersionGA}) + + result := VersionedToolsetFromString("test:ALPHA", api.VersionGA) + s.NotNil(result) + s.Equal(api.VersionAlpha, result.MinVersion) + + result = VersionedToolsetFromString("test:Beta", api.VersionGA) + s.NotNil(result) + s.Equal(api.VersionBeta, result.MinVersion) + }) +} + +func (s *VersionedToolsetsSuite) TestGetTools() { + alphaTool := api.ServerTool{ + Tool: api.Tool{Name: "alpha-tool"}, + Version: ptr.To(api.VersionAlpha), + } + betaTool := api.ServerTool{ + Tool: api.Tool{Name: "beta-tool"}, + Version: ptr.To(api.VersionBeta), + } + gaTool := api.ServerTool{ + Tool: api.Tool{Name: "ga-tool"}, + Version: ptr.To(api.VersionGA), + } + noVersionTool := api.ServerTool{ + Tool: api.Tool{Name: "no-version-tool"}, + // Version is nil - should inherit from toolset + } + + s.Run("filters tools by minimum version", func() { + toolset := &MockToolset{ + name: "test", + version: api.VersionGA, + tools: []api.ServerTool{alphaTool, betaTool, gaTool}, + } + Register(toolset) + + s.Run("MinVersion=alpha includes all tools", func() { + vt := VersionedToolsetFromString("test:alpha", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 3) + }) + + s.Run("MinVersion=beta excludes alpha tools", func() { + vt := VersionedToolsetFromString("test:beta", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 2) + for _, tool := range tools { + s.NotEqual("alpha-tool", tool.Tool.Name) + } + }) + + s.Run("MinVersion=ga excludes alpha and beta tools", func() { + vt := VersionedToolsetFromString("test:ga", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 1) + s.Equal("ga-tool", tools[0].Tool.Name) + }) + }) + + s.Run("tool at exactly MinVersion is included", func() { + toolset := &MockToolset{ + name: "boundary", + version: api.VersionGA, + tools: []api.ServerTool{betaTool}, + } + Register(toolset) + + vt := VersionedToolsetFromString("boundary:beta", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 1) + s.Equal("beta-tool", tools[0].Tool.Name) + }) + + s.Run("tool without explicit version inherits toolset version", func() { + s.Run("toolset is GA, tool included when MinVersion=ga", func() { + toolset := &MockToolset{ + name: "inherit-ga", + version: api.VersionGA, + tools: []api.ServerTool{noVersionTool}, + } + Register(toolset) + + vt := VersionedToolsetFromString("inherit-ga:ga", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 1) + }) + + s.Run("toolset is beta, tool excluded when MinVersion=ga", func() { + toolset := &MockToolset{ + name: "inherit-beta", + version: api.VersionBeta, + tools: []api.ServerTool{noVersionTool}, + } + Register(toolset) + + vt := VersionedToolsetFromString("inherit-beta:ga", api.VersionGA) + tools := vt.GetTools(nil) + s.Empty(tools) + }) + + s.Run("toolset is alpha, tool included when MinVersion=alpha", func() { + toolset := &MockToolset{ + name: "inherit-alpha", + version: api.VersionAlpha, + tools: []api.ServerTool{noVersionTool}, + } + Register(toolset) + + vt := VersionedToolsetFromString("inherit-alpha:alpha", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 1) + }) + }) + + s.Run("tool version overrides toolset version", func() { + // Toolset is GA but has an alpha tool + toolset := &MockToolset{ + name: "override", + version: api.VersionGA, + tools: []api.ServerTool{alphaTool, gaTool}, + } + Register(toolset) + + // With MinVersion=beta, alpha tool should be excluded even though toolset is GA + vt := VersionedToolsetFromString("override:beta", api.VersionGA) + tools := vt.GetTools(nil) + s.Len(tools, 1) + s.Equal("ga-tool", tools[0].Tool.Name) + }) + + s.Run("returns empty slice when no tools match", func() { + toolset := &MockToolset{ + name: "no-match", + version: api.VersionAlpha, + tools: []api.ServerTool{alphaTool}, + } + Register(toolset) + + vt := VersionedToolsetFromString("no-match:ga", api.VersionGA) + tools := vt.GetTools(nil) + s.Empty(tools) + s.NotNil(tools) // Should be empty slice, not nil + }) + + s.Run("returns empty slice when toolset has no tools", func() { + toolset := &MockToolset{ + name: "empty", + version: api.VersionGA, + tools: []api.ServerTool{}, + } + Register(toolset) + + vt := VersionedToolsetFromString("empty:alpha", api.VersionGA) + tools := vt.GetTools(nil) + s.Empty(tools) + }) + + s.Run("handles nil tools slice", func() { + toolset := &MockToolset{ + name: "nil-tools", + version: api.VersionGA, + tools: nil, + } + Register(toolset) + + vt := VersionedToolsetFromString("nil-tools:alpha", api.VersionGA) + tools := vt.GetTools(nil) + s.Empty(tools) + }) +} + +func (s *VersionedToolsetsSuite) TestGetPrompts() { + alphaPrompt := api.ServerPrompt{ + Prompt: api.Prompt{Name: "alpha-prompt"}, + Version: ptr.To(api.VersionAlpha), + } + betaPrompt := api.ServerPrompt{ + Prompt: api.Prompt{Name: "beta-prompt"}, + Version: ptr.To(api.VersionBeta), + } + gaPrompt := api.ServerPrompt{ + Prompt: api.Prompt{Name: "ga-prompt"}, + Version: ptr.To(api.VersionGA), + } + noVersionPrompt := api.ServerPrompt{ + Prompt: api.Prompt{Name: "no-version-prompt"}, + // Version is nil - should inherit from toolset + } + + s.Run("filters prompts by minimum version", func() { + toolset := &MockToolset{ + name: "prompt-test", + version: api.VersionGA, + prompts: []api.ServerPrompt{alphaPrompt, betaPrompt, gaPrompt}, + } + Register(toolset) + + s.Run("MinVersion=alpha includes all prompts", func() { + vt := VersionedToolsetFromString("prompt-test:alpha", api.VersionGA) + prompts := vt.GetPrompts() + s.Len(prompts, 3) + }) + + s.Run("MinVersion=beta excludes alpha prompts", func() { + vt := VersionedToolsetFromString("prompt-test:beta", api.VersionGA) + prompts := vt.GetPrompts() + s.Len(prompts, 2) + for _, prompt := range prompts { + s.NotEqual("alpha-prompt", prompt.Prompt.Name) + } + }) + + s.Run("MinVersion=ga excludes alpha and beta prompts", func() { + vt := VersionedToolsetFromString("prompt-test:ga", api.VersionGA) + prompts := vt.GetPrompts() + s.Len(prompts, 1) + s.Equal("ga-prompt", prompts[0].Prompt.Name) + }) + }) + + s.Run("prompt at exactly MinVersion is included", func() { + toolset := &MockToolset{ + name: "prompt-boundary", + version: api.VersionGA, + prompts: []api.ServerPrompt{betaPrompt}, + } + Register(toolset) + + vt := VersionedToolsetFromString("prompt-boundary:beta", api.VersionGA) + prompts := vt.GetPrompts() + s.Len(prompts, 1) + s.Equal("beta-prompt", prompts[0].Prompt.Name) + }) + + s.Run("prompt without explicit version inherits toolset version", func() { + s.Run("toolset is GA, prompt included when MinVersion=ga", func() { + toolset := &MockToolset{ + name: "prompt-inherit-ga", + version: api.VersionGA, + prompts: []api.ServerPrompt{noVersionPrompt}, + } + Register(toolset) + + vt := VersionedToolsetFromString("prompt-inherit-ga:ga", api.VersionGA) + prompts := vt.GetPrompts() + s.Len(prompts, 1) + }) + + s.Run("toolset is beta, prompt excluded when MinVersion=ga", func() { + toolset := &MockToolset{ + name: "prompt-inherit-beta", + version: api.VersionBeta, + prompts: []api.ServerPrompt{noVersionPrompt}, + } + Register(toolset) + + vt := VersionedToolsetFromString("prompt-inherit-beta:ga", api.VersionGA) + prompts := vt.GetPrompts() + s.Empty(prompts) + }) + }) + + s.Run("returns empty slice when toolset has no prompts", func() { + toolset := &MockToolset{ + name: "no-prompts", + version: api.VersionGA, + prompts: []api.ServerPrompt{}, + } + Register(toolset) + + vt := VersionedToolsetFromString("no-prompts:alpha", api.VersionGA) + prompts := vt.GetPrompts() + s.Empty(prompts) + }) + + s.Run("handles nil prompts slice", func() { + toolset := &MockToolset{ + name: "nil-prompts", + version: api.VersionGA, + prompts: nil, + } + Register(toolset) + + vt := VersionedToolsetFromString("nil-prompts:alpha", api.VersionGA) + prompts := vt.GetPrompts() + s.Empty(prompts) + }) +} + +func (s *VersionedToolsetsSuite) TestDelegatedMethods() { + toolset := &MockToolset{ + name: "delegated", + description: "A test toolset for delegation", + version: api.VersionBeta, + } + Register(toolset) + + vt := VersionedToolsetFromString("delegated:alpha", api.VersionGA) + + s.Run("GetName delegates to wrapped toolset", func() { + s.Equal("delegated", vt.GetName()) + }) + + s.Run("GetDescription delegates to wrapped toolset", func() { + s.Equal("A test toolset for delegation", vt.GetDescription()) + }) + + s.Run("GetVersion delegates to wrapped toolset", func() { + s.Equal(api.VersionBeta, vt.GetVersion()) + }) +} + +func (s *VersionedToolsetsSuite) TestValidateWithVersionedToolsets() { + Register(&MockToolset{name: "core", version: api.VersionGA}) + Register(&MockToolset{name: "helm", version: api.VersionBeta}) + + s.Run("validates plain toolset names", func() { + err := Validate([]string{"core", "helm"}) + s.NoError(err) + }) + + s.Run("validates versioned toolset names", func() { + err := Validate([]string{"core:alpha", "helm:beta"}) + s.NoError(err) + }) + + s.Run("validates mixed plain and versioned toolset names", func() { + err := Validate([]string{"core", "helm:alpha"}) + s.NoError(err) + }) + + s.Run("rejects invalid toolset names with version suffix", func() { + err := Validate([]string{"nonexistent:alpha"}) + s.Error(err) + s.Contains(err.Error(), "invalid toolset name") + }) +} + +func TestVersionedToolsets(t *testing.T) { + suite.Run(t, new(VersionedToolsetsSuite)) +}