From d9d35b9834937a1ab7bc195f1b13f3a013b05ba0 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Wed, 17 Sep 2025 11:06:17 +0200 Subject: [PATCH] test(toolsets): toolset specific metadata tests (#326) - Refactor tests to use testify (more clarity+composability for complex tests) - Tests for default toolsets - Tests for configured, granular toolsets Signed-off-by: Marc Nuri --- internal/test/kubernetes.go | 22 ++ internal/test/mcp.go | 52 +++ internal/test/mock_server.go | 60 ++- pkg/http/http_test.go | 7 +- pkg/mcp/common_test.go | 7 +- pkg/mcp/testdata/toolsets-config-tools.json | 22 ++ pkg/mcp/testdata/toolsets-core-tools.json | 416 ++++++++++++++++++++ pkg/mcp/testdata/toolsets-helm-tools.json | 88 +++++ pkg/mcp/toolsets_test.go | 214 +++++----- pkg/toolsets/toolsets_test.go | 8 + 10 files changed, 788 insertions(+), 108 deletions(-) create mode 100644 internal/test/kubernetes.go create mode 100644 internal/test/mcp.go create mode 100644 pkg/mcp/testdata/toolsets-config-tools.json create mode 100644 pkg/mcp/testdata/toolsets-core-tools.json create mode 100644 pkg/mcp/testdata/toolsets-helm-tools.json diff --git a/internal/test/kubernetes.go b/internal/test/kubernetes.go new file mode 100644 index 0000000..7e33f8a --- /dev/null +++ b/internal/test/kubernetes.go @@ -0,0 +1,22 @@ +package test + +import ( + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func KubeConfigFake() *clientcmdapi.Config { + fakeConfig := clientcmdapi.NewConfig() + fakeConfig.Clusters["fake"] = clientcmdapi.NewCluster() + fakeConfig.Clusters["fake"].Server = "https://127.0.0.1:6443" + fakeConfig.Clusters["additional-cluster"] = clientcmdapi.NewCluster() + fakeConfig.AuthInfos["fake"] = clientcmdapi.NewAuthInfo() + fakeConfig.AuthInfos["additional-auth"] = clientcmdapi.NewAuthInfo() + fakeConfig.Contexts["fake-context"] = clientcmdapi.NewContext() + fakeConfig.Contexts["fake-context"].Cluster = "fake" + fakeConfig.Contexts["fake-context"].AuthInfo = "fake" + fakeConfig.Contexts["additional-context"] = clientcmdapi.NewContext() + fakeConfig.Contexts["additional-context"].Cluster = "additional-cluster" + fakeConfig.Contexts["additional-context"].AuthInfo = "additional-auth" + fakeConfig.CurrentContext = "fake-context" + return fakeConfig +} diff --git a/internal/test/mcp.go b/internal/test/mcp.go new file mode 100644 index 0000000..8daaae4 --- /dev/null +++ b/internal/test/mcp.go @@ -0,0 +1,52 @@ +package test + +import ( + "net/http/httptest" + "testing" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "golang.org/x/net/context" +) + +type McpClient struct { + ctx context.Context + testServer *httptest.Server + *client.Client +} + +func NewMcpClient(t *testing.T, mcpHttpServer *server.StreamableHTTPServer) *McpClient { + require.NotNil(t, mcpHttpServer, "McpHttpServer must be provided") + var err error + ret := &McpClient{ctx: t.Context()} + ret.testServer = httptest.NewServer(mcpHttpServer) + ret.Client, err = client.NewStreamableHttpClient(ret.testServer.URL + "/mcp") + require.NoError(t, err, "Expected no error creating MCP client") + err = ret.Start(t.Context()) + require.NoError(t, err, "Expected no error starting MCP client") + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"} + _, err = ret.Initialize(t.Context(), initRequest) + require.NoError(t, err, "Expected no error initializing MCP client") + return ret +} + +func (m *McpClient) Close() { + if m.Client != nil { + _ = m.Client.Close() + } + if m.testServer != nil { + m.testServer.Close() + } +} + +// CallTool helper function to call a tool by name with arguments +func (m *McpClient) CallTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) { + callToolRequest := mcp.CallToolRequest{} + callToolRequest.Params.Name = name + callToolRequest.Params.Arguments = args + return m.Client.CallTool(m.ctx, callToolRequest) +} diff --git a/internal/test/mock_server.go b/internal/test/mock_server.go index b5f9047..c3c20d0 100644 --- a/internal/test/mock_server.go +++ b/internal/test/mock_server.go @@ -6,7 +6,10 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" + "testing" + "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -14,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream/spdy" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" ) @@ -46,7 +50,9 @@ func NewMockServer() *MockServer { } func (m *MockServer) Close() { - m.server.Close() + if m.server != nil { + m.server.Close() + } } func (m *MockServer) Handle(handler http.Handler) { @@ -57,21 +63,22 @@ func (m *MockServer) Config() *rest.Config { return m.config } -func (m *MockServer) KubeConfig() *api.Config { - fakeConfig := api.NewConfig() - fakeConfig.Clusters["fake"] = api.NewCluster() +func (m *MockServer) Kubeconfig() *api.Config { + fakeConfig := KubeConfigFake() fakeConfig.Clusters["fake"].Server = m.config.Host fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData - fakeConfig.AuthInfos["fake"] = api.NewAuthInfo() fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData - fakeConfig.Contexts["fake-context"] = api.NewContext() - fakeConfig.Contexts["fake-context"].Cluster = "fake" - fakeConfig.Contexts["fake-context"].AuthInfo = "fake" - fakeConfig.CurrentContext = "fake-context" return fakeConfig } +func (m *MockServer) KubeconfigFile(t *testing.T) string { + kubeconfig := filepath.Join(t.TempDir(), "config") + err := clientcmd.WriteToFile(*m.Kubeconfig(), kubeconfig) + require.NoError(t, err, "Expected no error writing kubeconfig file") + return kubeconfig +} + func WriteObject(w http.ResponseWriter, obj runtime.Object) { w.Header().Set("Content-Type", runtime.ContentTypeJSON) if err := json.NewEncoder(w).Encode(obj); err != nil { @@ -170,3 +177,38 @@ WaitForStreams: return ctx, nil } + +type InOpenShiftHandler struct { +} + +var _ http.Handler = (*InOpenShiftHandler)(nil) + +func (h *InOpenShiftHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-) + if req.URL.Path == "/api" { + _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`)) + return + } + // Request Performed by DiscoveryClient to Kube API (Get API Groups) + if req.URL.Path == "/apis" { + _, _ = w.Write([]byte(`{ + "kind":"APIGroupList", + "groups":[{ + "name":"project.openshift.io", + "versions":[{"groupVersion":"project.openshift.io/v1","version":"v1"}], + "preferredVersion":{"groupVersion":"project.openshift.io/v1","version":"v1"} + }]}`)) + return + } + if req.URL.Path == "/apis/project.openshift.io/v1" { + _, _ = w.Write([]byte(`{ + "kind":"APIResourceList", + "apiVersion":"v1", + "groupVersion":"project.openshift.io/v1", + "resources":[ + {"name":"projects","singularName":"","namespaced":false,"kind":"Project","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["pr"]} + ]}`)) + return + } +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 97dc2c9..c5f0231 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -13,7 +13,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "regexp" "strconv" "strings" @@ -24,7 +23,6 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc/oidctest" "golang.org/x/sync/errgroup" - "k8s.io/client-go/tools/clientcmd" "k8s.io/klog/v2" "k8s.io/klog/v2/textlogger" @@ -66,10 +64,7 @@ func (c *httpContext) beforeEach(t *testing.T) { } c.mockServer = test.NewMockServer() // Fake Kubernetes configuration - mockKubeConfig := c.mockServer.KubeConfig() - kubeConfig := filepath.Join(t.TempDir(), "config") - _ = clientcmd.WriteToFile(*mockKubeConfig, kubeConfig) - c.StaticConfig.KubeConfig = kubeConfig + c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t) // Capture logging c.klogState = klog.CaptureState() flags := flag.NewFlagSet("test", flag.ContinueOnError) diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index 702045a..efaece1 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -42,6 +42,7 @@ import ( "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" "sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/output" ) @@ -82,11 +83,9 @@ func TestMain(m *testing.M) { BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir), } adminSystemMasterBaseConfig, _ := envTest.Start() - au, err := envTest.AddUser(envTestUser, adminSystemMasterBaseConfig) - if err != nil { - panic(err) - } + au := test.Must(envTest.AddUser(envTestUser, adminSystemMasterBaseConfig)) envTestRestConfig = au.Config() + envTest.KubeConfig = test.Must(au.KubeConfig()) //Create test data as administrator ctx := context.Background() diff --git a/pkg/mcp/testdata/toolsets-config-tools.json b/pkg/mcp/testdata/toolsets-config-tools.json new file mode 100644 index 0000000..c176749 --- /dev/null +++ b/pkg/mcp/testdata/toolsets-config-tools.json @@ -0,0 +1,22 @@ +[ + { + "annotations": { + "title": "Configuration: View", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Get the current Kubernetes configuration content as a kubeconfig YAML", + "inputSchema": { + "type": "object", + "properties": { + "minified": { + "description": "Return a minified version of the configuration. If set to true, keeps only the current-context and the relevant pieces of the configuration for that context. If set to false, all contexts, clusters, auth-infos, and users are returned in the configuration. (Optional, default true)", + "type": "boolean" + } + } + }, + "name": "configuration_view" + } +] diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json new file mode 100644 index 0000000..62f0c34 --- /dev/null +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -0,0 +1,416 @@ +[ + { + "annotations": { + "title": "Events: List", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List all the Kubernetes events in the current cluster from all namespaces", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "description": "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces", + "type": "string" + } + } + }, + "name": "events_list" + }, + { + "annotations": { + "title": "Namespaces: List", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List all the Kubernetes namespaces in the current cluster", + "inputSchema": { + "type": "object" + }, + "name": "namespaces_list" + }, + { + "annotations": { + "title": "Pods: Delete", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Delete a Kubernetes Pod in the current or provided namespace with the provided name", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the Pod to delete", + "type": "string" + }, + "namespace": { + "description": "Namespace to delete the Pod from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "pods_delete" + }, + { + "annotations": { + "title": "Pods: Exec", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "description": "Command to execute in the Pod container. The first item is the command to be run, and the rest are the arguments to that command. Example: [\"ls\", \"-l\", \"/tmp\"]", + "items": { + "type": "string" + }, + "type": "array" + }, + "container": { + "description": "Name of the Pod container where the command will be executed (Optional)", + "type": "string" + }, + "name": { + "description": "Name of the Pod where the command will be executed", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Pod where the command will be executed", + "type": "string" + } + }, + "required": [ + "name", + "command" + ] + }, + "name": "pods_exec" + }, + { + "annotations": { + "title": "Pods: Get", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Get a Kubernetes Pod in the current or provided namespace with the provided name", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the Pod", + "type": "string" + }, + "namespace": { + "description": "Namespace to get the Pod from", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "pods_get" + }, + { + "annotations": { + "title": "Pods: List", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List all the Kubernetes pods in the current cluster from all namespaces", + "inputSchema": { + "type": "object", + "properties": { + "labelSelector": { + "description": "Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label", + "pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]", + "type": "string" + } + } + }, + "name": "pods_list" + }, + { + "annotations": { + "title": "Pods: List in Namespace", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List all the Kubernetes pods in the specified namespace in the current cluster", + "inputSchema": { + "type": "object", + "properties": { + "labelSelector": { + "description": "Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label", + "pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]", + "type": "string" + }, + "namespace": { + "description": "Namespace to list pods from", + "type": "string" + } + }, + "required": [ + "namespace" + ] + }, + "name": "pods_list_in_namespace" + }, + { + "annotations": { + "title": "Pods: Log", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name", + "inputSchema": { + "type": "object", + "properties": { + "container": { + "description": "Name of the Pod container to get the logs from (Optional)", + "type": "string" + }, + "name": { + "description": "Name of the Pod to get the logs from", + "type": "string" + }, + "namespace": { + "description": "Namespace to get the Pod logs from", + "type": "string" + }, + "previous": { + "description": "Return previous terminated container logs (Optional)", + "type": "boolean" + } + }, + "required": [ + "name" + ] + }, + "name": "pods_log" + }, + { + "annotations": { + "title": "Pods: Run", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name", + "inputSchema": { + "type": "object", + "properties": { + "image": { + "description": "Container Image to run in the Pod", + "type": "string" + }, + "name": { + "description": "Name of the Pod (Optional, random name if not provided)", + "type": "string" + }, + "namespace": { + "description": "Namespace to run the Pod in", + "type": "string" + }, + "port": { + "description": "TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)", + "type": "number" + } + }, + "required": [ + "image" + ] + }, + "name": "pods_run" + }, + { + "annotations": { + "title": "Pods: Top", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "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": { + "type": "object", + "properties": { + "all_namespaces": { + "default": true, + "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", + "type": "boolean" + }, + "label_selector": { + "description": "Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)", + "pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]", + "type": "string" + }, + "name": { + "description": "Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)", + "type": "string" + }, + "namespace": { + "description": "Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)", + "type": "string" + } + } + }, + "name": "pods_top" + }, + { + "annotations": { + "title": "Resources: Create or Update", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)", + "inputSchema": { + "type": "object", + "properties": { + "resource": { + "description": "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec", + "type": "string" + } + }, + "required": [ + "resource" + ] + }, + "name": "resources_create_or_update" + }, + { + "annotations": { + "title": "Resources: Delete", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)", + "inputSchema": { + "type": "object", + "properties": { + "apiVersion": { + "description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)", + "type": "string" + }, + "kind": { + "description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)", + "type": "string" + }, + "name": { + "description": "Name of the resource", + "type": "string" + }, + "namespace": { + "description": "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace", + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind", + "name" + ] + }, + "name": "resources_delete" + }, + { + "annotations": { + "title": "Resources: Get", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)", + "inputSchema": { + "type": "object", + "properties": { + "apiVersion": { + "description": "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)", + "type": "string" + }, + "kind": { + "description": "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)", + "type": "string" + }, + "name": { + "description": "Name of the resource", + "type": "string" + }, + "namespace": { + "description": "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace", + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind", + "name" + ] + }, + "name": "resources_get" + }, + { + "annotations": { + "title": "Resources: List", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n(common apiVersion and kind include: v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress)", + "inputSchema": { + "type": "object", + "properties": { + "apiVersion": { + "description": "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)", + "type": "string" + }, + "kind": { + "description": "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)", + "type": "string" + }, + "labelSelector": { + "description": "Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label", + "pattern": "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]", + "type": "string" + }, + "namespace": { + "description": "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces", + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind" + ] + }, + "name": "resources_list" + } +] diff --git a/pkg/mcp/testdata/toolsets-helm-tools.json b/pkg/mcp/testdata/toolsets-helm-tools.json new file mode 100644 index 0000000..c57dfc2 --- /dev/null +++ b/pkg/mcp/testdata/toolsets-helm-tools.json @@ -0,0 +1,88 @@ +[ + { + "annotations": { + "title": "Helm: Install", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Install a Helm chart in the current or provided namespace", + "inputSchema": { + "type": "object", + "properties": { + "chart": { + "description": "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)", + "type": "string" + }, + "name": { + "description": "Name of the Helm release (Optional, random name if not provided)", + "type": "string" + }, + "namespace": { + "description": "Namespace to install the Helm chart in (Optional, current namespace if not provided)", + "type": "string" + }, + "values": { + "description": "Values to pass to the Helm chart (Optional)", + "type": "object" + } + }, + "required": [ + "chart" + ] + }, + "name": "helm_install" + }, + { + "annotations": { + "title": "Helm: List", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)", + "inputSchema": { + "type": "object", + "properties": { + "all_namespaces": { + "description": "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)", + "type": "boolean" + }, + "namespace": { + "description": "Namespace to list Helm releases from (Optional, all namespaces if not provided)", + "type": "string" + } + } + }, + "name": "helm_list" + }, + { + "annotations": { + "title": "Helm: Uninstall", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Uninstall a Helm release in the current or provided namespace", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "description": "Name of the Helm release to uninstall", + "type": "string" + }, + "namespace": { + "description": "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_uninstall" + } +] diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index ac0cdee..24b75ec 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -5,115 +5,151 @@ import ( "os" "path/filepath" "runtime" - "slices" - "strings" "testing" "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/api" + configuration "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" ) -func TestDefaultToolsetTools(t *testing.T) { - expectedNames := []string{ - "configuration_view", - "events_list", - "helm_install", - "helm_list", - "helm_uninstall", - "namespaces_list", - "pods_list", - "pods_list_in_namespace", - "pods_get", - "pods_delete", - "pods_top", - "pods_log", - "pods_run", - "pods_exec", - "resources_list", - "resources_get", - "resources_create_or_update", - "resources_delete", +type ToolsetsSuite struct { + suite.Suite + originalToolsets []api.Toolset + *test.MockServer + *test.McpClient + Cfg *configuration.StaticConfig + mcpServer *Server +} + +func (s *ToolsetsSuite) SetupTest() { + s.originalToolsets = toolsets.Toolsets() + toolsets.Clear() + s.MockServer = test.NewMockServer() + s.Cfg = configuration.Default() + s.Cfg.KubeConfig = s.MockServer.KubeconfigFile(s.T()) +} + +func (s *ToolsetsSuite) TearDownTest() { + for _, toolset := range s.originalToolsets { + toolsets.Register(toolset) } - mcpCtx := &mcpContext{} - testCaseWithContext(t, mcpCtx, func(c *mcpContext) { - tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) - t.Run("ListTools returns tools", func(t *testing.T) { - if err != nil { - t.Fatalf("call ListTools failed %v", err) - return - } + s.MockServer.Close() +} + +func (s *ToolsetsSuite) TearDownSubTest() { + if s.McpClient != nil { + s.McpClient.Close() + } + if s.mcpServer != nil { + s.mcpServer.Close() + } +} + +func (s *ToolsetsSuite) TestNoToolsets() { + s.Run("No toolsets registered", func() { + s.Cfg.Toolsets = []string{} + s.InitMcpClient() + tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) + s.Run("ListTools returns no tools", func() { + s.NotNil(tools, "Expected tools from ListTools") + s.NoError(err, "Expected no error from ListTools") + s.Empty(tools.Tools, "Expected no tools from ListTools") }) - nameSet := make(map[string]bool) - for _, tool := range tools.Tools { - nameSet[tool.Name] = true - } - for _, name := range expectedNames { - t.Run("ListTools has "+name+" tool", func(t *testing.T) { - if nameSet[name] != true { - t.Fatalf("tool %s not found", name) - return - } - }) - } - t.Run("ListTools returns correct Tool metadata for toolset", func(t *testing.T) { + }) +} + +func (s *ToolsetsSuite) TestDefaultToolsetsTools() { + s.Run("Default configuration toolsets", func() { + s.Cfg.Toolsets = configuration.Default().Toolsets + toolsets.Register(&core.Toolset{}) + toolsets.Register(&config.Toolset{}) + toolsets.Register(&helm.Toolset{}) + s.InitMcpClient() + tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) + s.Run("ListTools returns tools", func() { + s.NotNil(tools, "Expected tools from ListTools") + s.NoError(err, "Expected no error from ListTools") + }) + s.Run("ListTools returns correct Tool metadata", func() { _, file, _, _ := runtime.Caller(0) expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools.json") expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath) - if err != nil { - t.Fatalf("failed to read expected tools metadata file: %v", err) - } + s.Require().NoErrorf(err, "failed to read expected tools metadata file: %v", err) metadata, err := json.MarshalIndent(tools.Tools, "", " ") - if err != nil { - t.Fatalf("failed to marshal tools metadata: %v", err) - } - assert.JSONEqf(t, string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected") + s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err) + s.JSONEq(string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected") }) }) } -func TestDefaultToolsetToolsInOpenShift(t *testing.T) { - mcpCtx := &mcpContext{ - before: inOpenShift, - after: inOpenShiftClear, - } - testCaseWithContext(t, mcpCtx, func(c *mcpContext) { - tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) - t.Run("ListTools returns tools", func(t *testing.T) { - if err != nil { - t.Fatalf("call ListTools failed %v", err) - } +func (s *ToolsetsSuite) TestDefaultToolsetsToolsInOpenShift() { + s.Run("Default configuration toolsets in OpenShift", func() { + s.Handle(&test.InOpenShiftHandler{}) + s.Cfg.Toolsets = configuration.Default().Toolsets + toolsets.Register(&core.Toolset{}) + toolsets.Register(&config.Toolset{}) + toolsets.Register(&helm.Toolset{}) + s.InitMcpClient() + tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) + s.Run("ListTools returns tools", func() { + s.NotNil(tools, "Expected tools from ListTools") + s.NoError(err, "Expected no error from ListTools") }) - t.Run("ListTools contains projects_list tool", func(t *testing.T) { - idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool { - return tool.Name == "projects_list" - }) - if idx == -1 { - t.Fatalf("tool projects_list not found") - } - }) - t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) { - idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool { - return tool.Name == "resources_list" - }) - if idx == -1 { - t.Fatalf("tool resources_list not found") - } - if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") { - t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description) - } - }) - t.Run("ListTools returns correct Tool metadata for toolset", func(t *testing.T) { + s.Run("ListTools returns correct Tool metadata", func() { _, file, _, _ := runtime.Caller(0) expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools-openshift.json") expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath) - if err != nil { - t.Fatalf("failed to read expected tools metadata file: %v", err) - } + s.Require().NoErrorf(err, "failed to read expected tools metadata file: %v", err) metadata, err := json.MarshalIndent(tools.Tools, "", " ") - if err != nil { - t.Fatalf("failed to marshal tools metadata: %v", err) - } - assert.JSONEqf(t, string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected") + s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err) + s.JSONEq(string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected") }) }) } + +func (s *ToolsetsSuite) TestGranularToolsetsTools() { + testCases := []api.Toolset{ + &core.Toolset{}, + &config.Toolset{}, + &helm.Toolset{}, + } + for _, testCase := range testCases { + s.Run("Toolset "+testCase.GetName(), func() { + toolsets.Register(testCase) + s.Cfg.Toolsets = []string{testCase.GetName()} + s.InitMcpClient() + tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{}) + s.Run("ListTools returns tools", func() { + s.NotNil(tools, "Expected tools from ListTools") + s.NoError(err, "Expected no error from ListTools") + }) + s.Run("ListTools returns correct Tool metadata", func() { + _, file, _, _ := runtime.Caller(0) + expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-"+testCase.GetName()+"-tools.json") + expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath) + s.Require().NoErrorf(err, "failed to read expected tools metadata file: %v", err) + metadata, err := json.MarshalIndent(tools.Tools, "", " ") + s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err) + s.JSONEq(string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected") + }) + }) + } +} + +func (s *ToolsetsSuite) InitMcpClient() { + var err error + s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg}) + s.Require().NoError(err, "Expected no error creating MCP server") + s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil)) +} + +func TestToolsets(t *testing.T) { + suite.Run(t, new(ToolsetsSuite)) +} diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 2857b01..05af11a 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -10,12 +10,20 @@ import ( type ToolsetsSuite struct { suite.Suite + originalToolsets []api.Toolset } func (s *ToolsetsSuite) SetupTest() { + s.originalToolsets = Toolsets() Clear() } +func (s *ToolsetsSuite) TearDownTest() { + for _, toolset := range s.originalToolsets { + Register(toolset) + } +} + type TestToolset struct { name string description string