diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go new file mode 100644 index 0000000..2317de3 --- /dev/null +++ b/pkg/api/toolsets.go @@ -0,0 +1,99 @@ +package api + +import ( + "context" + "encoding/json" + + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/google/jsonschema-go/jsonschema" +) + +type ServerTool struct { + Tool Tool + Handler ToolHandlerFunc +} + +type Toolset interface { + // GetName returns the name of the toolset. + // Used to identify the toolset in configuration, logs, and command-line arguments. + // Examples: "core", "metrics", "helm" + GetName() string + GetDescription() string + GetTools(k *internalk8s.Manager) []ServerTool +} + +type ToolCallRequest interface { + GetArguments() map[string]any +} + +type ToolCallResult struct { + // Raw content returned by the tool. + Content string + // Error (non-protocol) to send back to the LLM. + Error error +} + +func NewToolCallResult(content string, err error) *ToolCallResult { + return &ToolCallResult{ + Content: content, + Error: err, + } +} + +type ToolHandlerParams struct { + context.Context + *internalk8s.Kubernetes + ToolCallRequest + ListOutput output.Output +} + +type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error) + +type Tool struct { + // The name of the tool. + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // A human-readable description of the tool. + // + // This can be used by clients to improve the LLM's understanding of available + // tools. It can be thought of like a "hint" to the model. + Description string `json:"description,omitempty"` + // Additional tool information. + Annotations ToolAnnotations `json:"annotations"` + // A JSON Schema object defining the expected parameters for the tool. + InputSchema *jsonschema.Schema +} + +type ToolAnnotations struct { + // Human-readable title for the tool + Title string `json:"title,omitempty"` + // If true, the tool does not modify its environment. + ReadOnlyHint *bool `json:"readOnlyHint,omitempty"` + // If true, the tool may perform destructive updates to its environment. If + // false, the tool performs only additive updates. + // + // (This property is meaningful only when ReadOnlyHint == false.) + DestructiveHint *bool `json:"destructiveHint,omitempty"` + // If true, calling the tool repeatedly with the same arguments will have no + // additional effect on its environment. + // + // (This property is meaningful only when ReadOnlyHint == false.) + IdempotentHint *bool `json:"idempotentHint,omitempty"` + // If true, this tool may interact with an "open world" of external entities. If + // false, the tool's domain of interaction is closed. For example, the world of + // a web search tool is open, whereas that of a memory tool is not. + OpenWorldHint *bool `json:"openWorldHint,omitempty"` +} + +func ToRawMessage(v any) json.RawMessage { + if v == nil { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return nil + } + return b +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index da04caa..3ab5fc3 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -21,6 +21,7 @@ import ( "time" "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc/oidctest" "golang.org/x/sync/errgroup" @@ -87,7 +88,7 @@ func (c *httpContext) beforeEach(t *testing.T) { } c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port) mcpServer, err := mcp.NewServer(mcp.Configuration{ - Toolset: mcp.Toolsets()[0], + Toolset: toolsets.Toolsets()[0], StaticConfig: c.StaticConfig, }) if err != nil { diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 5575391..b2fa2e7 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" @@ -115,7 +116,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)") 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.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(mcp.ToolsetNames(), ", ")+")") + cmd.Flags().StringVar(&o.Toolset, "toolset", o.Toolset, "MCP toolset to use (one of: "+strings.Join(toolsets.ToolsetNames(), ", ")+")") 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") @@ -237,9 +238,9 @@ func (m *MCPServerOptions) Validate() error { } func (m *MCPServerOptions) Run() error { - toolset := mcp.ToolsetFromString(m.Toolset) + toolset := toolsets.ToolsetFromString(m.Toolset) if toolset == nil { - return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(mcp.ToolsetNames(), ", ")) + return fmt.Errorf("invalid toolset name: %s, valid names are: %s", m.Toolset, strings.Join(toolsets.ToolsetNames(), ", ")) } listOutput := output.FromString(m.StaticConfig.ListOutput) if listOutput == nil { diff --git a/pkg/kubernetes/configuration.go b/pkg/kubernetes/configuration.go index df88530..75a4a7e 100644 --- a/pkg/kubernetes/configuration.go +++ b/pkg/kubernetes/configuration.go @@ -81,24 +81,24 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig { return m.clientCmdConfig } -func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) { +func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) { var cfg clientcmdapi.Config var err error - if m.IsInCluster() { + if k.manager.IsInCluster() { cfg = *clientcmdapi.NewConfig() cfg.Clusters["cluster"] = &clientcmdapi.Cluster{ - Server: m.cfg.Host, - InsecureSkipTLSVerify: m.cfg.Insecure, + Server: k.manager.cfg.Host, + InsecureSkipTLSVerify: k.manager.cfg.Insecure, } cfg.AuthInfos["user"] = &clientcmdapi.AuthInfo{ - Token: m.cfg.BearerToken, + Token: k.manager.cfg.BearerToken, } cfg.Contexts["context"] = &clientcmdapi.Context{ Cluster: "cluster", AuthInfo: "user", } cfg.CurrentContext = "context" - } else if cfg, err = m.clientCmdConfig.RawConfig(); err != nil { + } else if cfg, err = k.manager.clientCmdConfig.RawConfig(); err != nil { return nil, err } if minify { diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index 177fb40..439cb7d 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -14,8 +14,6 @@ import ( "testing" "time" - "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" @@ -32,7 +30,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/clientcmd/api" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" toolswatch "k8s.io/client-go/tools/watch" "k8s.io/klog/v2" "k8s.io/klog/v2/textlogger" @@ -43,6 +41,11 @@ import ( "sigs.k8s.io/controller-runtime/tools/setup-envtest/store" "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" "sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full" ) // envTest has an expensive setup, so we only want to do it once per entire test run. @@ -103,7 +106,7 @@ func TestMain(m *testing.M) { } type mcpContext struct { - toolset Toolset + toolset api.Toolset listOutput output.Output logLevel int @@ -127,7 +130,7 @@ func (c *mcpContext) beforeEach(t *testing.T) { c.tempDir = t.TempDir() c.withKubeConfig(nil) if c.toolset == nil { - c.toolset = &Full{} + c.toolset = &full.Full{} } if c.listOutput == nil { c.listOutput = output.Yaml @@ -188,7 +191,7 @@ func (c *mcpContext) afterEach() { } func testCase(t *testing.T, test func(c *mcpContext)) { - testCaseWithContext(t, &mcpContext{toolset: &Full{}}, test) + testCaseWithContext(t, &mcpContext{toolset: &full.Full{}}, test) } func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpContext)) { @@ -198,23 +201,23 @@ func testCaseWithContext(t *testing.T, mcpCtx *mcpContext, test func(c *mcpConte } // withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config -func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config { - fakeConfig := api.NewConfig() - fakeConfig.Clusters["fake"] = api.NewCluster() +func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config { + fakeConfig := clientcmdapi.NewConfig() + fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster() fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443" - fakeConfig.Clusters["additional-cluster"] = api.NewCluster() - fakeConfig.AuthInfos["fake"] = api.NewAuthInfo() - fakeConfig.AuthInfos["additional-auth"] = api.NewAuthInfo() + fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster() + fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo() + fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo() if rc != nil { fakeConfig.Clusters["fake"].Server = rc.Host fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.CAData fakeConfig.AuthInfos["fake"].ClientKeyData = rc.KeyData fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.CertData } - fakeConfig.Contexts["fake-context"] = api.NewContext() + fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext() fakeConfig.Contexts["fake-context"].Cluster = "fake" fakeConfig.Contexts["fake-context"].AuthInfo = "fake" - fakeConfig.Contexts["additional-context"] = api.NewContext() + fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext() fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster" fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth" fakeConfig.CurrentContext = "fake-context" diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go new file mode 100644 index 0000000..5dfe13a --- /dev/null +++ b/pkg/mcp/m3labs.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.ServerTool, error) { + m3labTools := make([]server.ServerTool, 0) + for _, tool := range tools { + m3labTool := mcp.Tool{ + Name: tool.Tool.Name, + Description: tool.Tool.Description, + Annotations: mcp.ToolAnnotation{ + Title: tool.Tool.Annotations.Title, + ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint, + DestructiveHint: tool.Tool.Annotations.DestructiveHint, + IdempotentHint: tool.Tool.Annotations.IdempotentHint, + OpenWorldHint: tool.Tool.Annotations.OpenWorldHint, + }, + } + if tool.Tool.InputSchema != nil { + schema, err := json.Marshal(tool.Tool.InputSchema) + if err != nil { + return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err) + } + m3labTool.RawInputSchema = schema + } + m3labHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + k, err := s.k.Derived(ctx) + if err != nil { + return nil, err + } + result, err := tool.Handler(api.ToolHandlerParams{ + Context: ctx, + Kubernetes: k, + ToolCallRequest: request, + ListOutput: s.configuration.ListOutput, + }) + if err != nil { + return nil, err + } + return NewTextResult(result.Content, result.Error), nil + } + m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: m3labHandler}) + } + return m3labTools, nil +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index f0750d0..cb19ad3 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -7,6 +7,7 @@ import ( "net/http" "slices" + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" authenticationapiv1 "k8s.io/api/authentication/v1" @@ -24,13 +25,13 @@ type ContextKey string const TokenScopesContextKey = ContextKey("TokenScopesContextKey") type Configuration struct { - Toolset Toolset + Toolset api.Toolset ListOutput output.Output StaticConfig *config.StaticConfig } -func (c *Configuration) isToolApplicable(tool ServerTool) bool { +func (c *Configuration) isToolApplicable(tool api.ServerTool) bool { if c.StaticConfig.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) { return false } @@ -88,15 +89,15 @@ func (s *Server) reloadKubernetesClient() error { return err } s.k = k - applicableTools := make([]ServerTool, 0) - for _, tool := range s.configuration.Toolset.GetTools(s) { + applicableTools := make([]api.ServerTool, 0) + for _, tool := range s.configuration.Toolset.GetTools(s.k) { if !s.configuration.isToolApplicable(tool) { continue } applicableTools = append(applicableTools, tool) s.enabledTools = append(s.enabledTools, tool.Tool.Name) } - m3labsServerTools, err := ServerToolToM3LabsServerTool(applicableTools) + m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools) if err != nil { return fmt.Errorf("failed to convert tools: %v", err) } diff --git a/pkg/mcp/mcp_tools_test.go b/pkg/mcp/mcp_tools_test.go index 9ca8cf1..2a56ffe 100644 --- a/pkg/mcp/mcp_tools_test.go +++ b/pkg/mcp/mcp_tools_test.go @@ -161,7 +161,7 @@ func TestToolCallLogging(t *testing.T) { } }) sensitiveHeaders := []string{ - "Authorization", + "Authorization:", // TODO: Add more sensitive headers as needed } t.Run("Does not log sensitive headers", func(t *testing.T) { diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go new file mode 100644 index 0000000..5a473b1 --- /dev/null +++ b/pkg/mcp/modules.go @@ -0,0 +1,3 @@ +package mcp + +import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full" diff --git a/pkg/mcp/namespaces.go b/pkg/mcp/namespaces.go deleted file mode 100644 index b752b74..0000000 --- a/pkg/mcp/namespaces.go +++ /dev/null @@ -1,75 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" - "k8s.io/utils/ptr" - - "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" -) - -func (s *Server) initNamespaces() []ServerTool { - ret := make([]ServerTool, 0) - ret = append(ret, ServerTool{ - Tool: Tool{ - Name: "namespaces_list", - Description: "List all the Kubernetes namespaces in the current cluster", - InputSchema: &jsonschema.Schema{ - Type: "object", - }, - Annotations: ToolAnnotations{ - Title: "Namespaces: List", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(false), - OpenWorldHint: ptr.To(true), - }, - }, Handler: s.namespacesList, - }) - if s.k.IsOpenShift(context.Background()) { - ret = append(ret, ServerTool{ - Tool: Tool{ - Name: "projects_list", - Description: "List all the OpenShift projects in the current cluster", - InputSchema: &jsonschema.Schema{ - Type: "object", - }, - Annotations: ToolAnnotations{ - Title: "Projects: List", - ReadOnlyHint: ptr.To(true), - DestructiveHint: ptr.To(false), - IdempotentHint: ptr.To(false), - OpenWorldHint: ptr.To(true), - }, - }, Handler: s.projectsList, - }) - } - return ret -} - -func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - derived, err := s.k.Derived(ctx) - if err != nil { - return nil, err - } - ret, err := derived.NamespacesList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()}) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil - } - return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil -} - -func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - derived, err := s.k.Derived(ctx) - if err != nil { - return nil, err - } - ret, err := derived.ProjectsList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()}) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list projects: %v", err)), nil - } - return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil -} diff --git a/pkg/mcp/toolsets.go b/pkg/mcp/toolsets.go deleted file mode 100644 index cc5481a..0000000 --- a/pkg/mcp/toolsets.go +++ /dev/null @@ -1,151 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "slices" - - "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -type Toolset interface { - GetName() string - GetDescription() string - GetTools(s *Server) []ServerTool -} - -type ServerTool struct { - Tool Tool - Handler ToolHandlerFunc -} - -type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) - -type Tool struct { - // The name of the tool. - // Intended for programmatic or logical use, but used as a display name in past - // specs or fallback (if title isn't present). - Name string `json:"name"` - // A human-readable description of the tool. - // - // This can be used by clients to improve the LLM's understanding of available - // tools. It can be thought of like a "hint" to the model. - Description string `json:"description,omitempty"` - // Additional tool information. - Annotations ToolAnnotations `json:"annotations"` - // A JSON Schema object defining the expected parameters for the tool. - InputSchema *jsonschema.Schema -} - -type ToolAnnotations struct { - // Human-readable title for the tool - Title string `json:"title,omitempty"` - // If true, the tool does not modify its environment. - ReadOnlyHint *bool `json:"readOnlyHint,omitempty"` - // If true, the tool may perform destructive updates to its environment. If - // false, the tool performs only additive updates. - // - // (This property is meaningful only when ReadOnlyHint == false.) - DestructiveHint *bool `json:"destructiveHint,omitempty"` - // If true, calling the tool repeatedly with the same arguments will have no - // additional effect on its environment. - // - // (This property is meaningful only when ReadOnlyHint == false.) - IdempotentHint *bool `json:"idempotentHint,omitempty"` - // If true, this tool may interact with an "open world" of external entities. If - // false, the tool's domain of interaction is closed. For example, the world of - // a web search tool is open, whereas that of a memory tool is not. - OpenWorldHint *bool `json:"openWorldHint,omitempty"` -} - -var toolsets []Toolset - -func Register(toolset Toolset) { - toolsets = append(toolsets, toolset) -} - -func Toolsets() []Toolset { - return toolsets -} - -func ToolsetNames() []string { - names := make([]string, 0) - for _, toolset := range Toolsets() { - names = append(names, toolset.GetName()) - } - return names -} - -func ToolsetFromString(name string) Toolset { - for _, toolset := range Toolsets() { - if toolset.GetName() == name { - return toolset - } - } - return nil -} - -func ToRawMessage(v any) json.RawMessage { - if v == nil { - return nil - } - b, err := json.Marshal(v) - if err != nil { - return nil - } - return b -} - -func ServerToolToM3LabsServerTool(tools []ServerTool) ([]server.ServerTool, error) { - m3labTools := make([]server.ServerTool, 0) - for _, tool := range tools { - m3labTool := mcp.Tool{ - Name: tool.Tool.Name, - Description: tool.Tool.Description, - Annotations: mcp.ToolAnnotation{ - Title: tool.Tool.Annotations.Title, - ReadOnlyHint: tool.Tool.Annotations.ReadOnlyHint, - DestructiveHint: tool.Tool.Annotations.DestructiveHint, - IdempotentHint: tool.Tool.Annotations.IdempotentHint, - OpenWorldHint: tool.Tool.Annotations.OpenWorldHint, - }, - } - if tool.Tool.InputSchema != nil { - schema, err := json.Marshal(tool.Tool.InputSchema) - if err != nil { - return nil, fmt.Errorf("failed to marshal tool input schema for tool %s: %v", tool.Tool.Name, err) - } - m3labTool.RawInputSchema = schema - } - m3labTools = append(m3labTools, server.ServerTool{Tool: m3labTool, Handler: server.ToolHandlerFunc(tool.Handler)}) - } - return m3labTools, nil -} - -type Full struct{} - -var _ Toolset = (*Full)(nil) - -func (p *Full) GetName() string { - return "full" -} -func (p *Full) GetDescription() string { - return "Complete toolset with all tools and extended outputs" -} -func (p *Full) GetTools(s *Server) []ServerTool { - return slices.Concat( - s.initConfiguration(), - s.initEvents(), - s.initNamespaces(), - s.initPods(), - s.initResources(), - s.initHelm(), - ) -} - -func init() { - Register(&Full{}) -} diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index f8bae3a..2a81797 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/full" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" ) @@ -34,7 +35,7 @@ func TestFullToolsetTools(t *testing.T) { "resources_create_or_update", "resources_delete", } - mcpCtx := &mcpContext{toolset: &Full{}} + mcpCtx := &mcpContext{toolset: &full.Full{}} testCaseWithContext(t, mcpCtx, func(c *mcpContext) { tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) t.Run("ListTools returns tools", func(t *testing.T) { @@ -73,7 +74,7 @@ func TestFullToolsetTools(t *testing.T) { func TestFullToolsetToolsInOpenShift(t *testing.T) { mcpCtx := &mcpContext{ - toolset: &Full{}, + toolset: &full.Full{}, before: inOpenShift, after: inOpenShiftClear, } diff --git a/pkg/mcp/configuration.go b/pkg/toolsets/full/configuration.go similarity index 68% rename from pkg/mcp/configuration.go rename to pkg/toolsets/full/configuration.go index 237c26c..483991e 100644 --- a/pkg/mcp/configuration.go +++ b/pkg/toolsets/full/configuration.go @@ -1,19 +1,18 @@ -package mcp +package full import ( - "context" "fmt" "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" "k8s.io/utils/ptr" + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/output" ) -func (s *Server) initConfiguration() []ServerTool { - tools := []ServerTool{ - {Tool: Tool{ +func initConfiguration() []api.ServerTool { + tools := []api.ServerTool{ + {Tool: api.Tool{ Name: "configuration_view", Description: "Get the current Kubernetes configuration content as a kubeconfig YAML", InputSchema: &jsonschema.Schema{ @@ -28,31 +27,31 @@ func (s *Server) initConfiguration() []ServerTool { }, }, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Configuration: View", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.configurationView}, + }, Handler: configurationView}, } return tools } -func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func configurationView(params api.ToolHandlerParams) (*api.ToolCallResult, error) { minify := true - minified := ctr.GetArguments()["minified"] + minified := params.GetArguments()["minified"] if _, ok := minified.(bool); ok { minify = minified.(bool) } - ret, err := s.k.ConfigurationView(minify) + ret, err := params.ConfigurationView(minify) if err != nil { - return NewTextResult("", fmt.Errorf("failed to get configuration: %v", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to get configuration: %v", err)), nil } configurationYaml, err := output.MarshalYaml(ret) if err != nil { err = fmt.Errorf("failed to get configuration: %v", err) } - return NewTextResult(configurationYaml, err), nil + return api.NewToolCallResult(configurationYaml, err), nil } diff --git a/pkg/mcp/events.go b/pkg/toolsets/full/events.go similarity index 57% rename from pkg/mcp/events.go rename to pkg/toolsets/full/events.go index 22668a9..48fae71 100644 --- a/pkg/mcp/events.go +++ b/pkg/toolsets/full/events.go @@ -1,19 +1,18 @@ -package mcp +package full import ( - "context" "fmt" "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" "k8s.io/utils/ptr" + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/output" ) -func (s *Server) initEvents() []ServerTool { - return []ServerTool{ - {Tool: Tool{ +func initEvents() []api.ServerTool { + return []api.ServerTool{ + {Tool: api.Tool{ Name: "events_list", Description: "List all the Kubernetes events in the current cluster from all namespaces", InputSchema: &jsonschema.Schema{ @@ -25,36 +24,32 @@ func (s *Server) initEvents() []ServerTool { }, }, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Events: List", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.eventsList}, + }, Handler: eventsList}, } } -func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := ctr.GetArguments()["namespace"] +func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"] if namespace == nil { namespace = "" } - derived, err := s.k.Derived(ctx) + eventMap, err := params.EventsList(params, namespace.(string)) if err != nil { - return nil, err - } - eventMap, err := derived.EventsList(ctx, namespace.(string)) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil } if len(eventMap) == 0 { - return NewTextResult("No events found", nil), nil + return api.NewToolCallResult("No events found", nil), nil } yamlEvents, err := output.MarshalYaml(eventMap) if err != nil { err = fmt.Errorf("failed to list events in all namespaces: %v", err) } - return NewTextResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil + return api.NewToolCallResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil } diff --git a/pkg/mcp/helm.go b/pkg/toolsets/full/helm.go similarity index 59% rename from pkg/mcp/helm.go rename to pkg/toolsets/full/helm.go index ce04d17..1389f20 100644 --- a/pkg/mcp/helm.go +++ b/pkg/toolsets/full/helm.go @@ -1,17 +1,17 @@ -package mcp +package full import ( - "context" "fmt" "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" ) -func (s *Server) initHelm() []ServerTool { - return []ServerTool{ - {Tool: Tool{ +func initHelm() []api.ServerTool { + return []api.ServerTool{ + {Tool: api.Tool{ Name: "helm_install", Description: "Install a Helm chart in the current or provided namespace", InputSchema: &jsonschema.Schema{ @@ -37,15 +37,15 @@ func (s *Server) initHelm() []ServerTool { }, Required: []string{"chart"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Helm: Install", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install OpenWorldHint: ptr.To(true), }, - }, Handler: s.helmInstall}, - {Tool: Tool{ + }, Handler: helmInstall}, + {Tool: api.Tool{ Name: "helm_list", Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)", InputSchema: &jsonschema.Schema{ @@ -61,15 +61,15 @@ func (s *Server) initHelm() []ServerTool { }, }, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Helm: List", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.helmList}, - {Tool: Tool{ + }, Handler: helmList}, + {Tool: api.Tool{ Name: "helm_uninstall", Description: "Uninstall a Helm release in the current or provided namespace", InputSchema: &jsonschema.Schema{ @@ -86,83 +86,71 @@ func (s *Server) initHelm() []ServerTool { }, Required: []string{"name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Helm: Uninstall", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(true), IdempotentHint: ptr.To(true), OpenWorldHint: ptr.To(true), }, - }, Handler: s.helmUninstall}, + }, Handler: helmUninstall}, } } -func (s *Server) helmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func helmInstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { var chart string ok := false - if chart, ok = ctr.GetArguments()["chart"].(string); !ok { - return NewTextResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil + if chart, ok = params.GetArguments()["chart"].(string); !ok { + return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil } values := map[string]interface{}{} - if v, ok := ctr.GetArguments()["values"].(map[string]interface{}); ok { + if v, ok := params.GetArguments()["values"].(map[string]interface{}); ok { values = v } name := "" - if v, ok := ctr.GetArguments()["name"].(string); ok { + if v, ok := params.GetArguments()["name"].(string); ok { name = v } namespace := "" - if v, ok := ctr.GetArguments()["namespace"].(string); ok { + if v, ok := params.GetArguments()["namespace"].(string); ok { namespace = v } - derived, err := s.k.Derived(ctx) + ret, err := params.NewHelm().Install(params, chart, values, name, namespace) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil } - ret, err := derived.NewHelm().Install(ctx, chart, values, name, namespace) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil - } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } -func (s *Server) helmList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func helmList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { allNamespaces := false - if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { + if v, ok := params.GetArguments()["all_namespaces"].(bool); ok { allNamespaces = v } namespace := "" - if v, ok := ctr.GetArguments()["namespace"].(string); ok { + if v, ok := params.GetArguments()["namespace"].(string); ok { namespace = v } - derived, err := s.k.Derived(ctx) + ret, err := params.NewHelm().List(namespace, allNamespaces) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil } - ret, err := derived.NewHelm().List(namespace, allNamespaces) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil - } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } -func (s *Server) helmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { var name string ok := false - if name, ok = ctr.GetArguments()["name"].(string); !ok { - return NewTextResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil + if name, ok = params.GetArguments()["name"].(string); !ok { + return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil } namespace := "" - if v, ok := ctr.GetArguments()["namespace"].(string); ok { + if v, ok := params.GetArguments()["namespace"].(string); ok { namespace = v } - derived, err := s.k.Derived(ctx) + ret, err := params.NewHelm().Uninstall(name, namespace) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil } - ret, err := derived.NewHelm().Uninstall(name, namespace) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil - } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } diff --git a/pkg/toolsets/full/namespaces.go b/pkg/toolsets/full/namespaces.go new file mode 100644 index 0000000..fcbae2e --- /dev/null +++ b/pkg/toolsets/full/namespaces.go @@ -0,0 +1,68 @@ +package full + +import ( + "context" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +func initNamespaces(k *internalk8s.Manager) []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "namespaces_list", + Description: "List all the Kubernetes namespaces in the current cluster", + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Namespaces: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: namespacesList, + }) + if k.IsOpenShift(context.Background()) { + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "projects_list", + Description: "List all the OpenShift projects in the current cluster", + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Projects: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: projectsList, + }) + } + return ret +} + +func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ret, err := params.NamespacesList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()}) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} + +func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ret, err := params.ProjectsList(params, kubernetes.ResourceListOptions{AsTable: params.ListOutput.AsTable()}) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %v", err)), nil + } + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil +} diff --git a/pkg/mcp/pods.go b/pkg/toolsets/full/pods.go similarity index 62% rename from pkg/mcp/pods.go rename to pkg/toolsets/full/pods.go index 331e288..f417888 100644 --- a/pkg/mcp/pods.go +++ b/pkg/toolsets/full/pods.go @@ -1,23 +1,22 @@ -package mcp +package full import ( "bytes" - "context" "errors" "fmt" "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" "k8s.io/kubectl/pkg/metricsutil" "k8s.io/utils/ptr" + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" ) -func (s *Server) initPods() []ServerTool { - return []ServerTool{ - {Tool: Tool{ +func initPods() []api.ServerTool { + return []api.ServerTool{ + {Tool: api.Tool{ Name: "pods_list", Description: "List all the Kubernetes pods in the current cluster from all namespaces", InputSchema: &jsonschema.Schema{ @@ -30,15 +29,15 @@ func (s *Server) initPods() []ServerTool { }, }, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: List", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsListInAllNamespaces}, - {Tool: Tool{ + }, Handler: podsListInAllNamespaces}, + {Tool: api.Tool{ Name: "pods_list_in_namespace", Description: "List all the Kubernetes pods in the specified namespace in the current cluster", InputSchema: &jsonschema.Schema{ @@ -56,15 +55,15 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"namespace"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: List in Namespace", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsListInNamespace}, - {Tool: Tool{ + }, Handler: podsListInNamespace}, + {Tool: api.Tool{ Name: "pods_get", Description: "Get a Kubernetes Pod in the current or provided namespace with the provided name", InputSchema: &jsonschema.Schema{ @@ -81,15 +80,15 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Get", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsGet}, - {Tool: Tool{ + }, Handler: podsGet}, + {Tool: api.Tool{ Name: "pods_delete", Description: "Delete a Kubernetes Pod in the current or provided namespace with the provided name", InputSchema: &jsonschema.Schema{ @@ -106,15 +105,15 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Delete", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(true), IdempotentHint: ptr.To(true), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsDelete}, - {Tool: Tool{ + }, Handler: podsDelete}, + {Tool: api.Tool{ Name: "pods_top", Description: "List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace", InputSchema: &jsonschema.Schema{ @@ -123,7 +122,7 @@ func (s *Server) initPods() []ServerTool { "all_namespaces": { Type: "boolean", Description: "If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace", - Default: ToRawMessage(true), + Default: api.ToRawMessage(true), }, "namespace": { Type: "string", @@ -140,15 +139,15 @@ func (s *Server) initPods() []ServerTool { }, }, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Top", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(true), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsTop}, - {Tool: Tool{ + }, Handler: podsTop}, + {Tool: api.Tool{ Name: "pods_exec", Description: "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command", InputSchema: &jsonschema.Schema{ @@ -176,15 +175,15 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"name", "command"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Exec", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(true), // Depending on the Pod's entrypoint, executing certain commands may kill the Pod IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsExec}, - {Tool: Tool{ + }, Handler: podsExec}, + {Tool: api.Tool{ Name: "pods_log", Description: "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name", InputSchema: &jsonschema.Schema{ @@ -209,15 +208,15 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Log", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsLog}, - {Tool: Tool{ + }, Handler: podsLog}, + {Tool: api.Tool{ Name: "pods_run", Description: "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name", InputSchema: &jsonschema.Schema{ @@ -242,144 +241,124 @@ func (s *Server) initPods() []ServerTool { }, Required: []string{"image"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Pods: Run", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.podsRun}, + }, Handler: podsRun}, } } -func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - labelSelector := ctr.GetArguments()["labelSelector"] +func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + labelSelector := params.GetArguments()["labelSelector"] resourceListOptions := kubernetes.ResourceListOptions{ - AsTable: s.configuration.ListOutput.AsTable(), + AsTable: params.ListOutput.AsTable(), } if labelSelector != nil { resourceListOptions.LabelSelector = labelSelector.(string) } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsListInAllNamespaces(params, resourceListOptions) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil } - ret, err := derived.PodsListInAllNamespaces(ctx, resourceListOptions) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil - } - return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil } -func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { - return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil + return api.NewToolCallResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil } resourceListOptions := kubernetes.ResourceListOptions{ - AsTable: s.configuration.ListOutput.AsTable(), + AsTable: params.ListOutput.AsTable(), } - labelSelector := ctr.GetArguments()["labelSelector"] + labelSelector := params.GetArguments()["labelSelector"] if labelSelector != nil { resourceListOptions.LabelSelector = labelSelector.(string) } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsListInNamespace(params, ns.(string), resourceListOptions) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil } - ret, err := derived.PodsListInNamespace(ctx, ns.(string), resourceListOptions) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil - } - return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil } -func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { ns = "" } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to get pod, missing argument name")), nil } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsGet(params, ns.(string), name.(string)) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil } - ret, err := derived.PodsGet(ctx, ns.(string), name.(string)) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil - } - return NewTextResult(output.MarshalYaml(ret)), nil + return api.NewToolCallResult(output.MarshalYaml(ret)), nil } -func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { ns = "" } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to delete pod, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to delete pod, missing argument name")), nil } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsDelete(params, ns.(string), name.(string)) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil } - ret, err := derived.PodsDelete(ctx, ns.(string), name.(string)) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %v", name, ns, err)), nil - } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } -func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true} - if v, ok := ctr.GetArguments()["namespace"].(string); ok { + if v, ok := params.GetArguments()["namespace"].(string); ok { podsTopOptions.Namespace = v } - if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { + if v, ok := params.GetArguments()["all_namespaces"].(bool); ok { podsTopOptions.AllNamespaces = v } - if v, ok := ctr.GetArguments()["name"].(string); ok { + if v, ok := params.GetArguments()["name"].(string); ok { podsTopOptions.Name = v } - if v, ok := ctr.GetArguments()["label_selector"].(string); ok { + if v, ok := params.GetArguments()["label_selector"].(string); ok { podsTopOptions.LabelSelector = v } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsTop(params, podsTopOptions) if err != nil { - return nil, err - } - ret, err := derived.PodsTop(ctx, podsTopOptions) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil } buf := new(bytes.Buffer) printer := metricsutil.NewTopCmdPrinter(buf) err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true) if err != nil { - return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %v", err)), nil } - return NewTextResult(buf.String(), nil), nil + return api.NewToolCallResult(buf.String(), nil), nil } -func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { ns = "" } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to exec in pod, missing argument name")), nil } - container := ctr.GetArguments()["container"] + container := params.GetArguments()["container"] if container == nil { container = "" } - commandArg := ctr.GetArguments()["command"] + commandArg := params.GetArguments()["command"] command := make([]string, 0) if _, ok := commandArg.([]interface{}); ok { for _, cmd := range commandArg.([]interface{}) { @@ -388,80 +367,68 @@ func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Ca } } } else { - return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil + return api.NewToolCallResult("", errors.New("failed to exec in pod, invalid command argument")), nil } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsExec(params, ns.(string), name.(string), container.(string), command) if err != nil { - return nil, err - } - ret, err := derived.PodsExec(ctx, ns.(string), name.(string), container.(string), command) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil } else if ret == "" { ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns) } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } -func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { ns = "" } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to get pod log, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to get pod log, missing argument name")), nil } - container := ctr.GetArguments()["container"] + container := params.GetArguments()["container"] if container == nil { container = "" } - previous := ctr.GetArguments()["previous"] + previous := params.GetArguments()["previous"] var previousBool bool if previous != nil { previousBool = previous.(bool) } - derived, err := s.k.Derived(ctx) + ret, err := params.PodsLog(params, ns.(string), name.(string), container.(string), previousBool) if err != nil { - return nil, err - } - ret, err := derived.PodsLog(ctx, ns.(string), name.(string), container.(string), previousBool) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil } else if ret == "" { ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns) } - return NewTextResult(ret, err), nil + return api.NewToolCallResult(ret, err), nil } -func (s *Server) podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ns := ctr.GetArguments()["namespace"] +func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + ns := params.GetArguments()["namespace"] if ns == nil { ns = "" } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { name = "" } - image := ctr.GetArguments()["image"] + image := params.GetArguments()["image"] if image == nil { - return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil + return api.NewToolCallResult("", errors.New("failed to run pod, missing argument image")), nil } - port := ctr.GetArguments()["port"] + port := params.GetArguments()["port"] if port == nil { port = float64(0) } - derived, err := s.k.Derived(ctx) + resources, err := params.PodsRun(params, ns.(string), name.(string), image.(string), int32(port.(float64))) if err != nil { - return nil, err - } - resources, err := derived.PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64))) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to run pod %s in namespace %s: %v", name, ns, err)), nil } marshalledYaml, err := output.MarshalYaml(resources) if err != nil { err = fmt.Errorf("failed to run pod: %v", err) } - return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil + return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil } diff --git a/pkg/mcp/resources.go b/pkg/toolsets/full/resources.go similarity index 64% rename from pkg/mcp/resources.go rename to pkg/toolsets/full/resources.go index eb8550a..20ff867 100644 --- a/pkg/mcp/resources.go +++ b/pkg/toolsets/full/resources.go @@ -1,4 +1,4 @@ -package mcp +package full import ( "context" @@ -6,22 +6,23 @@ import ( "fmt" "github.com/google/jsonschema-go/jsonschema" - "github.com/mark3labs/mcp-go/mcp" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" + "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" ) -func (s *Server) initResources() []ServerTool { +func initResources(k *internalk8s.Manager) []api.ServerTool { commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress" - if s.k.IsOpenShift(context.Background()) { + if k.IsOpenShift(context.Background()) { commonApiVersion += ", route.openshift.io/v1 Route" } commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion) - return []ServerTool{ - {Tool: Tool{ + return []api.ServerTool{ + {Tool: api.Tool{ Name: "resources_list", Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion, InputSchema: &jsonschema.Schema{ @@ -47,15 +48,15 @@ func (s *Server) initResources() []ServerTool { }, Required: []string{"apiVersion", "kind"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Resources: List", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.resourcesList}, - {Tool: Tool{ + }, Handler: resourcesList}, + {Tool: api.Tool{ Name: "resources_get", Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion, InputSchema: &jsonschema.Schema{ @@ -80,15 +81,15 @@ func (s *Server) initResources() []ServerTool { }, Required: []string{"apiVersion", "kind", "name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Resources: Get", ReadOnlyHint: ptr.To(true), DestructiveHint: ptr.To(false), IdempotentHint: ptr.To(false), OpenWorldHint: ptr.To(true), }, - }, Handler: s.resourcesGet}, - {Tool: Tool{ + }, Handler: resourcesGet}, + {Tool: api.Tool{ Name: "resources_create_or_update", Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion, InputSchema: &jsonschema.Schema{ @@ -101,15 +102,15 @@ func (s *Server) initResources() []ServerTool { }, Required: []string{"resource"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Resources: Create or Update", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(true), IdempotentHint: ptr.To(true), OpenWorldHint: ptr.To(true), }, - }, Handler: s.resourcesCreateOrUpdate}, - {Tool: Tool{ + }, Handler: resourcesCreateOrUpdate}, + {Tool: api.Tool{ Name: "resources_delete", Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion, InputSchema: &jsonschema.Schema{ @@ -134,149 +135,133 @@ func (s *Server) initResources() []ServerTool { }, Required: []string{"apiVersion", "kind", "name"}, }, - Annotations: ToolAnnotations{ + Annotations: api.ToolAnnotations{ Title: "Resources: Delete", ReadOnlyHint: ptr.To(false), DestructiveHint: ptr.To(true), IdempotentHint: ptr.To(true), OpenWorldHint: ptr.To(true), }, - }, Handler: s.resourcesDelete}, + }, Handler: resourcesDelete}, } } -func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := ctr.GetArguments()["namespace"] +func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"] if namespace == nil { namespace = "" } - labelSelector := ctr.GetArguments()["labelSelector"] + labelSelector := params.GetArguments()["labelSelector"] resourceListOptions := kubernetes.ResourceListOptions{ - AsTable: s.configuration.ListOutput.AsTable(), + AsTable: params.ListOutput.AsTable(), } if labelSelector != nil { l, ok := labelSelector.(string) if !ok { - return NewTextResult("", fmt.Errorf("labelSelector is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("labelSelector is not a string")), nil } resourceListOptions.LabelSelector = l } - gvk, err := parseGroupVersionKind(ctr.GetArguments()) + gvk, err := parseGroupVersionKind(params.GetArguments()) if err != nil { - return NewTextResult("", fmt.Errorf("failed to list resources, %s", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to list resources, %s", err)), nil } ns, ok := namespace.(string) if !ok { - return NewTextResult("", fmt.Errorf("namespace is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil } - derived, err := s.k.Derived(ctx) + ret, err := params.ResourcesList(params, gvk, ns, resourceListOptions) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil } - ret, err := derived.ResourcesList(ctx, gvk, ns, resourceListOptions) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil - } - return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil + return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil } -func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := ctr.GetArguments()["namespace"] +func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"] if namespace == nil { namespace = "" } - gvk, err := parseGroupVersionKind(ctr.GetArguments()) + gvk, err := parseGroupVersionKind(params.GetArguments()) if err != nil { - return NewTextResult("", fmt.Errorf("failed to get resource, %s", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to get resource, %s", err)), nil } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to get resource, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to get resource, missing argument name")), nil } ns, ok := namespace.(string) if !ok { - return NewTextResult("", fmt.Errorf("namespace is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil } n, ok := name.(string) if !ok { - return NewTextResult("", fmt.Errorf("name is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil } - derived, err := s.k.Derived(ctx) + ret, err := params.ResourcesGet(params, gvk, ns, n) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil } - ret, err := derived.ResourcesGet(ctx, gvk, ns, n) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil - } - return NewTextResult(output.MarshalYaml(ret)), nil + return api.NewToolCallResult(output.MarshalYaml(ret)), nil } -func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resource := ctr.GetArguments()["resource"] +func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + resource := params.GetArguments()["resource"] if resource == nil || resource == "" { - return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil + return api.NewToolCallResult("", errors.New("failed to create or update resources, missing argument resource")), nil } r, ok := resource.(string) if !ok { - return NewTextResult("", fmt.Errorf("resource is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("resource is not a string")), nil } - derived, err := s.k.Derived(ctx) + resources, err := params.ResourcesCreateOrUpdate(params, r) if err != nil { - return nil, err - } - resources, err := derived.ResourcesCreateOrUpdate(ctx, r) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil } marshalledYaml, err := output.MarshalYaml(resources) if err != nil { err = fmt.Errorf("failed to create or update resources:: %v", err) } - return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil + return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil } -func (s *Server) resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - namespace := ctr.GetArguments()["namespace"] +func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"] if namespace == nil { namespace = "" } - gvk, err := parseGroupVersionKind(ctr.GetArguments()) + gvk, err := parseGroupVersionKind(params.GetArguments()) if err != nil { - return NewTextResult("", fmt.Errorf("failed to delete resource, %s", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to delete resource, %s", err)), nil } - name := ctr.GetArguments()["name"] + name := params.GetArguments()["name"] if name == nil { - return NewTextResult("", errors.New("failed to delete resource, missing argument name")), nil + return api.NewToolCallResult("", errors.New("failed to delete resource, missing argument name")), nil } ns, ok := namespace.(string) if !ok { - return NewTextResult("", fmt.Errorf("namespace is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil } n, ok := name.(string) if !ok { - return NewTextResult("", fmt.Errorf("name is not a string")), nil + return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil } - derived, err := s.k.Derived(ctx) + err = params.ResourcesDelete(params, gvk, ns, n) if err != nil { - return nil, err + return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil } - err = derived.ResourcesDelete(ctx, gvk, ns, n) - if err != nil { - return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil - } - return NewTextResult("Resource deleted successfully", err), nil + return api.NewToolCallResult("Resource deleted successfully", err), nil } func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) { diff --git a/pkg/toolsets/full/toolset.go b/pkg/toolsets/full/toolset.go new file mode 100644 index 0000000..301e3fd --- /dev/null +++ b/pkg/toolsets/full/toolset.go @@ -0,0 +1,36 @@ +package full + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +type Full struct{} + +var _ api.Toolset = (*Full)(nil) + +func (p *Full) GetName() string { + return "full" +} + +func (p *Full) GetDescription() string { + return "Complete toolset with all tools and extended outputs" +} + +func (p *Full) GetTools(k *internalk8s.Manager) []api.ServerTool { + return slices.Concat( + initConfiguration(), + initEvents(), + initNamespaces(k), + initPods(), + initResources(k), + initHelm(), + ) +} + +func init() { + toolsets.Register(&Full{}) +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go new file mode 100644 index 0000000..621be22 --- /dev/null +++ b/pkg/toolsets/toolsets.go @@ -0,0 +1,40 @@ +package toolsets + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +var toolsets []api.Toolset + +// Clear removes all registered toolsets, TESTING PURPOSES ONLY. +func Clear() { + toolsets = []api.Toolset{} +} + +func Register(toolset api.Toolset) { + toolsets = append(toolsets, toolset) +} + +func Toolsets() []api.Toolset { + return toolsets +} + +func ToolsetNames() []string { + names := make([]string, 0) + for _, toolset := range Toolsets() { + names = append(names, toolset.GetName()) + } + slices.Sort(names) + return names +} + +func ToolsetFromString(name string) api.Toolset { + for _, toolset := range Toolsets() { + if toolset.GetName() == name { + return toolset + } + } + return nil +} diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go new file mode 100644 index 0000000..f717e4e --- /dev/null +++ b/pkg/toolsets/toolsets_test.go @@ -0,0 +1,60 @@ +package toolsets + +import ( + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/stretchr/testify/suite" +) + +type ToolsetsSuite struct { + suite.Suite +} + +func (s *ToolsetsSuite) SetupTest() { + Clear() +} + +type TestToolset struct { + name string + description string +} + +func (t *TestToolset) GetName() string { return t.name } + +func (t *TestToolset) GetDescription() string { return t.description } + +func (t *TestToolset) GetTools(k *kubernetes.Manager) []api.ServerTool { return nil } + +var _ api.Toolset = (*TestToolset)(nil) + +func (s *ToolsetsSuite) TestToolsetNames() { + s.Run("Returns empty list if no toolsets registered", func() { + s.Empty(ToolsetNames(), "Expected empty list of toolset names") + }) + + Register(&TestToolset{name: "z"}) + Register(&TestToolset{name: "b"}) + Register(&TestToolset{name: "1"}) + s.Run("Returns sorted list of registered toolset names", func() { + names := ToolsetNames() + s.Equal([]string{"1", "b", "z"}, names, "Expected sorted list of toolset names") + }) +} + +func (s *ToolsetsSuite) TestToolsetFromString() { + s.Run("Returns nil if toolset not found", func() { + s.Nil(ToolsetFromString("non-existent"), "Expected nil for non-existent toolset") + }) + s.Run("Returns the correct toolset if found", func() { + Register(&TestToolset{name: "existent"}) + res := ToolsetFromString("existent") + s.NotNil(res, "Expected to find the registered toolset") + s.Equal("existent", res.GetName(), "Expected to find the registered toolset by name") + }) +} + +func TestToolsets(t *testing.T) { + suite.Run(t, new(ToolsetsSuite)) +}