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 <marc@marcnuri.com>
This commit is contained in:
Marc Nuri
2025-09-17 11:06:17 +02:00
committed by GitHub
parent 48cf204a89
commit d9d35b9834
10 changed files with 788 additions and 108 deletions

View File

@@ -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
}

52
internal/test/mcp.go Normal file
View File

@@ -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)
}

View File

@@ -6,7 +6,10 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@@ -14,6 +17,7 @@ import (
"k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream"
"k8s.io/apimachinery/pkg/util/httpstream/spdy" "k8s.io/apimachinery/pkg/util/httpstream/spdy"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
) )
@@ -46,7 +50,9 @@ func NewMockServer() *MockServer {
} }
func (m *MockServer) Close() { func (m *MockServer) Close() {
m.server.Close() if m.server != nil {
m.server.Close()
}
} }
func (m *MockServer) Handle(handler http.Handler) { func (m *MockServer) Handle(handler http.Handler) {
@@ -57,21 +63,22 @@ func (m *MockServer) Config() *rest.Config {
return m.config return m.config
} }
func (m *MockServer) KubeConfig() *api.Config { func (m *MockServer) Kubeconfig() *api.Config {
fakeConfig := api.NewConfig() fakeConfig := KubeConfigFake()
fakeConfig.Clusters["fake"] = api.NewCluster()
fakeConfig.Clusters["fake"].Server = m.config.Host fakeConfig.Clusters["fake"].Server = m.config.Host
fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData
fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData 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 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) { func WriteObject(w http.ResponseWriter, obj runtime.Object) {
w.Header().Set("Content-Type", runtime.ContentTypeJSON) w.Header().Set("Content-Type", runtime.ContentTypeJSON)
if err := json.NewEncoder(w).Encode(obj); err != nil { if err := json.NewEncoder(w).Encode(obj); err != nil {
@@ -170,3 +177,38 @@ WaitForStreams:
return ctx, nil 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
}
}

View File

@@ -13,7 +13,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -24,7 +23,6 @@ import (
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest" "github.com/coreos/go-oidc/v3/oidc/oidctest"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2" "k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger" "k8s.io/klog/v2/textlogger"
@@ -66,10 +64,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
} }
c.mockServer = test.NewMockServer() c.mockServer = test.NewMockServer()
// Fake Kubernetes configuration // Fake Kubernetes configuration
mockKubeConfig := c.mockServer.KubeConfig() c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t)
kubeConfig := filepath.Join(t.TempDir(), "config")
_ = clientcmd.WriteToFile(*mockKubeConfig, kubeConfig)
c.StaticConfig.KubeConfig = kubeConfig
// Capture logging // Capture logging
c.klogState = klog.CaptureState() c.klogState = klog.CaptureState()
flags := flag.NewFlagSet("test", flag.ContinueOnError) flags := flag.NewFlagSet("test", flag.ContinueOnError)

View File

@@ -42,6 +42,7 @@ import (
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows" "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/config"
"github.com/containers/kubernetes-mcp-server/pkg/output" "github.com/containers/kubernetes-mcp-server/pkg/output"
) )
@@ -82,11 +83,9 @@ func TestMain(m *testing.M) {
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir), BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
} }
adminSystemMasterBaseConfig, _ := envTest.Start() adminSystemMasterBaseConfig, _ := envTest.Start()
au, err := envTest.AddUser(envTestUser, adminSystemMasterBaseConfig) au := test.Must(envTest.AddUser(envTestUser, adminSystemMasterBaseConfig))
if err != nil {
panic(err)
}
envTestRestConfig = au.Config() envTestRestConfig = au.Config()
envTest.KubeConfig = test.Must(au.KubeConfig())
//Create test data as administrator //Create test data as administrator
ctx := context.Background() ctx := context.Background()

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -5,115 +5,151 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings"
"testing" "testing"
"github.com/mark3labs/mcp-go/mcp" "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) { type ToolsetsSuite struct {
expectedNames := []string{ suite.Suite
"configuration_view", originalToolsets []api.Toolset
"events_list", *test.MockServer
"helm_install", *test.McpClient
"helm_list", Cfg *configuration.StaticConfig
"helm_uninstall", mcpServer *Server
"namespaces_list", }
"pods_list",
"pods_list_in_namespace", func (s *ToolsetsSuite) SetupTest() {
"pods_get", s.originalToolsets = toolsets.Toolsets()
"pods_delete", toolsets.Clear()
"pods_top", s.MockServer = test.NewMockServer()
"pods_log", s.Cfg = configuration.Default()
"pods_run", s.Cfg.KubeConfig = s.MockServer.KubeconfigFile(s.T())
"pods_exec", }
"resources_list",
"resources_get", func (s *ToolsetsSuite) TearDownTest() {
"resources_create_or_update", for _, toolset := range s.originalToolsets {
"resources_delete", toolsets.Register(toolset)
} }
mcpCtx := &mcpContext{} s.MockServer.Close()
testCaseWithContext(t, mcpCtx, func(c *mcpContext) { }
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) { func (s *ToolsetsSuite) TearDownSubTest() {
if err != nil { if s.McpClient != nil {
t.Fatalf("call ListTools failed %v", err) s.McpClient.Close()
return }
} 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
} func (s *ToolsetsSuite) TestDefaultToolsetsTools() {
for _, name := range expectedNames { s.Run("Default configuration toolsets", func() {
t.Run("ListTools has "+name+" tool", func(t *testing.T) { s.Cfg.Toolsets = configuration.Default().Toolsets
if nameSet[name] != true { toolsets.Register(&core.Toolset{})
t.Fatalf("tool %s not found", name) toolsets.Register(&config.Toolset{})
return toolsets.Register(&helm.Toolset{})
} s.InitMcpClient()
}) tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
} s.Run("ListTools returns tools", func() {
t.Run("ListTools returns correct Tool metadata for toolset", func(t *testing.T) { 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) _, file, _, _ := runtime.Caller(0)
expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools.json") expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools.json")
expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath) expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath)
if err != nil { s.Require().NoErrorf(err, "failed to read expected tools metadata file: %v", err)
t.Fatalf("failed to read expected tools metadata file: %v", err)
}
metadata, err := json.MarshalIndent(tools.Tools, "", " ") metadata, err := json.MarshalIndent(tools.Tools, "", " ")
if err != nil { s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
t.Fatalf("failed to marshal tools metadata: %v", err) s.JSONEq(string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected")
}
assert.JSONEqf(t, string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected")
}) })
}) })
} }
func TestDefaultToolsetToolsInOpenShift(t *testing.T) { func (s *ToolsetsSuite) TestDefaultToolsetsToolsInOpenShift() {
mcpCtx := &mcpContext{ s.Run("Default configuration toolsets in OpenShift", func() {
before: inOpenShift, s.Handle(&test.InOpenShiftHandler{})
after: inOpenShiftClear, s.Cfg.Toolsets = configuration.Default().Toolsets
} toolsets.Register(&core.Toolset{})
testCaseWithContext(t, mcpCtx, func(c *mcpContext) { toolsets.Register(&config.Toolset{})
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{}) toolsets.Register(&helm.Toolset{})
t.Run("ListTools returns tools", func(t *testing.T) { s.InitMcpClient()
if err != nil { tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
t.Fatalf("call ListTools failed %v", err) 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) { s.Run("ListTools returns correct Tool metadata", func() {
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) {
_, file, _, _ := runtime.Caller(0) _, file, _, _ := runtime.Caller(0)
expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools-openshift.json") expectedMetadataPath := filepath.Join(filepath.Dir(file), "testdata", "toolsets-full-tools-openshift.json")
expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath) expectedMetadataBytes, err := os.ReadFile(expectedMetadataPath)
if err != nil { s.Require().NoErrorf(err, "failed to read expected tools metadata file: %v", err)
t.Fatalf("failed to read expected tools metadata file: %v", err)
}
metadata, err := json.MarshalIndent(tools.Tools, "", " ") metadata, err := json.MarshalIndent(tools.Tools, "", " ")
if err != nil { s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
t.Fatalf("failed to marshal tools metadata: %v", err) s.JSONEq(string(expectedMetadataBytes), string(metadata), "tools metadata does not match expected")
}
assert.JSONEqf(t, 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))
}

View File

@@ -10,12 +10,20 @@ import (
type ToolsetsSuite struct { type ToolsetsSuite struct {
suite.Suite suite.Suite
originalToolsets []api.Toolset
} }
func (s *ToolsetsSuite) SetupTest() { func (s *ToolsetsSuite) SetupTest() {
s.originalToolsets = Toolsets()
Clear() Clear()
} }
func (s *ToolsetsSuite) TearDownTest() {
for _, toolset := range s.originalToolsets {
Register(toolset)
}
}
type TestToolset struct { type TestToolset struct {
name string name string
description string description string