mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
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:
22
internal/test/kubernetes.go
Normal file
22
internal/test/kubernetes.go
Normal 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
52
internal/test/mcp.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
22
pkg/mcp/testdata/toolsets-config-tools.json
vendored
Normal file
22
pkg/mcp/testdata/toolsets-config-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
416
pkg/mcp/testdata/toolsets-core-tools.json
vendored
Normal file
416
pkg/mcp/testdata/toolsets-core-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
88
pkg/mcp/testdata/toolsets-helm-tools.json
vendored
Normal file
88
pkg/mcp/testdata/toolsets-helm-tools.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user