diff --git a/cmd/thv-operator/README.md b/cmd/thv-operator/README.md index a49c5a3fe..c4470f769 100644 --- a/cmd/thv-operator/README.md +++ b/cmd/thv-operator/README.md @@ -213,6 +213,125 @@ kubectl describe mcpserver | `permissionProfile` | Permission profile configuration | No | - | | `tools` | Allow-list filter on the list of tools | No | - | +### Kagent Integration + +The ToolHive operator supports optional integration with [kagent](https://kagent.dev), allowing kagent agents to discover and use MCP servers managed by ToolHive. When enabled, the operator automatically creates kagent resources that reference the ToolHive-managed MCP servers. + +The integration supports both: +- **kagent v1alpha1**: Creates `ToolServer` resources +- **kagent v1alpha2**: Creates `RemoteMCPServer` resources (when available) + +The operator automatically detects which kagent API version is available in your cluster and creates the appropriate resources. + +#### Enabling Kagent Integration + +To enable kagent integration, set the following Helm value when installing the operator: + +```bash +helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator \ + --set kagentIntegration.enabled=true \ + -n toolhive-system --create-namespace +``` + +Or add it to your values file: + +```yaml +kagentIntegration: + enabled: true +``` + +#### Configuration Options + +You can control the kagent API version preference via environment variable: + +```yaml +# In your values file +kagentIntegration: + enabled: true + apiVersion: v1alpha2 # Optional: prefer v1alpha2 when available (defaults to v1alpha1) +``` + +This sets the `KAGENT_API_VERSION` environment variable in the operator deployment. + +#### How It Works + +When kagent integration is enabled: + +1. The operator detects which kagent API version is available in your cluster +2. For each ToolHive MCPServer resource created, the operator automatically creates: + - A kagent `ToolServer` resource (v1alpha1), OR + - A kagent `RemoteMCPServer` resource (v1alpha2) +3. The kagent resource references the ToolHive-managed MCP server service URL +4. The resource is owned by the MCPServer, ensuring it's deleted when the MCPServer is removed +5. Kagent agents can then discover and use these resources to access the MCP servers + +The kagent resources are created with: +- Name: `toolhive-` +- Namespace: Same as the MCPServer +- Transport configuration: + - v1alpha1: Mapped to config types (sse → sse, streamable-http → streamableHttp, stdio → sse) + - v1alpha2: Mapped to protocols (sse → SSE, streamable-http → STREAMABLE_HTTP, stdio → SSE) +- Service URL: Points to the ToolHive proxy service + +#### Requirements + +- Kagent must be installed in your cluster (either v1alpha1 or v1alpha2) +- The operator needs permissions to manage kagent resources (automatically configured when integration is enabled) + +#### Example + +When you create a ToolHive MCPServer: + +```yaml +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: github + namespace: toolhive-system +spec: + image: ghcr.io/github/github-mcp-server + transport: sse + port: 8080 +``` + +With kagent integration enabled, the operator automatically creates one of the following: + +**For kagent v1alpha1:** +```yaml +apiVersion: kagent.dev/v1alpha1 +kind: ToolServer +metadata: + name: toolhive-github + namespace: toolhive-system + labels: + toolhive.stacklok.dev/managed-by: toolhive-operator + toolhive.stacklok.dev/mcpserver: github +spec: + description: "ToolHive MCP Server: github" + config: + type: sse + sse: + url: http://mcp-github-proxy.toolhive-system.svc.cluster.local:8080 +``` + +**For kagent v1alpha2:** +```yaml +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: toolhive-github + namespace: toolhive-system + labels: + toolhive.stacklok.dev/managed-by: toolhive-operator + toolhive.stacklok.dev/mcpserver: github +spec: + description: "ToolHive MCP Server: github" + url: http://mcp-github-proxy.toolhive-system.svc.cluster.local:8080 + protocol: SSE +``` + +Kagent agents can then reference these resources to use the GitHub MCP server in their workflows. + ### Permission Profiles Permission profiles can be configured in two ways: diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index b5fcce7f9..d3f53e39a 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -318,6 +318,13 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } } + // Ensure kagent ToolServer resource if integration is enabled + if err := r.ensureKagentToolServer(ctx, mcpServer); err != nil { + ctxLogger.Error(err, "Failed to ensure kagent ToolServer") + // Log the error but don't fail the reconciliation + // This allows the ToolHive MCPServer to work even if kagent integration fails + } + // Check if the deployment spec changed if r.deploymentNeedsUpdate(deployment, mcpServer) { // Update the deployment @@ -1248,6 +1255,13 @@ func (r *MCPServerReconciler) finalizeMCPServer(ctx context.Context, m *mcpv1alp return fmt.Errorf("failed to check RunConfig ConfigMap %s: %w", runConfigName, err) } + // Step 5: Delete associated kagent ToolServer if it exists + // This should be handled by owner references, but we explicitly delete for safety + if err := r.deleteKagentToolServer(ctx, m); err != nil { + // Log the error but don't fail the finalization + ctxLogger.Error(err, "Failed to delete kagent ToolServer during finalization") + } + // The owner references will automatically delete the deployment and service // when the MCPServer is deleted, so we don't need to do anything here. return nil diff --git a/cmd/thv-operator/controllers/mcpserver_kagent.go b/cmd/thv-operator/controllers/mcpserver_kagent.go new file mode 100644 index 000000000..aedfea42b --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_kagent.go @@ -0,0 +1,371 @@ +package controllers + +import ( + "context" + "fmt" + "os" + "strconv" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +const ( + kagentAPIVersionV1Alpha1 = "v1alpha1" + kagentAPIVersionV1Alpha2 = "v1alpha2" +) + +// kagentToolServerGVK defines the GroupVersionKind for kagent v1alpha1 ToolServer +var kagentToolServerGVK = schema.GroupVersionKind{ + Group: "kagent.dev", + Version: kagentAPIVersionV1Alpha1, + Kind: "ToolServer", +} + +// kagentRemoteMCPServerGVK defines the GroupVersionKind for kagent v1alpha2 RemoteMCPServer +var kagentRemoteMCPServerGVK = schema.GroupVersionKind{ + Group: "kagent.dev", + Version: kagentAPIVersionV1Alpha2, + Kind: "RemoteMCPServer", +} + +// Constants for kagent config types +const ( + // v1alpha1 config types + kagentConfigTypeSSE = "sse" + kagentConfigTypeStreamableHTTP = "streamableHttp" + + // v1alpha2 protocol types + kagentProtocolSSE = "SSE" + kagentProtocolStreamableHTTP = "STREAMABLE_HTTP" + + // Environment variable for kagent API version preference + kagentAPIVersionEnv = "KAGENT_API_VERSION" +) + +// isKagentIntegrationEnabled checks if kagent integration is enabled via environment variable +func isKagentIntegrationEnabled() bool { + enabled := os.Getenv("KAGENT_INTEGRATION_ENABLED") + if enabled == "" { + return false + } + result, err := strconv.ParseBool(enabled) + if err != nil { + return false + } + return result +} + +// getPreferredKagentAPIVersion returns the preferred kagent API version +// Defaults to v1alpha1 for backward compatibility, but can be overridden +// via KAGENT_API_VERSION environment variable +func getPreferredKagentAPIVersion() string { + version := os.Getenv(kagentAPIVersionEnv) + if version == "v1alpha2" { + return "v1alpha2" + } + // Default to v1alpha1 for backward compatibility + return "v1alpha1" +} + +// detectKagentAPIVersion detects which kagent API version is available in the cluster +func (r *MCPServerReconciler) detectKagentAPIVersion(ctx context.Context) string { + // First check if user has a preference + preferred := getPreferredKagentAPIVersion() + + // Try to list resources of the preferred version to see if it's available + if preferred == kagentAPIVersionV1Alpha2 { + // Try v1alpha2 RemoteMCPServer + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: kagentAPIVersionV1Alpha2, + Kind: "RemoteMCPServerList", + }) + + // We just want to check if the API exists, limit to 1 item + if err := r.List(ctx, list, &client.ListOptions{Limit: 1}); err == nil { + return kagentAPIVersionV1Alpha2 + } + } + + // Try v1alpha1 ToolServer + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: kagentAPIVersionV1Alpha1, + Kind: "ToolServerList", + }) + + if err := r.List(ctx, list, &client.ListOptions{Limit: 1}); err == nil { + return kagentAPIVersionV1Alpha1 + } + + // If neither works, return the preferred version anyway + // The actual resource creation will fail with a clear error + return preferred +} + +// ensureKagentToolServer ensures a kagent resource exists for the ToolHive MCPServer +// It automatically detects and uses the appropriate kagent API version +func (r *MCPServerReconciler) ensureKagentToolServer(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { + logger := log.FromContext(ctx) + + // Check if kagent integration is enabled + if !isKagentIntegrationEnabled() { + // If not enabled, ensure any existing kagent resources are deleted + return r.deleteKagentToolServer(ctx, mcpServer) + } + + // Detect which kagent API version to use + apiVersion := r.detectKagentAPIVersion(ctx) + logger.V(1).Info("Using kagent API version", "version", apiVersion) + + // Create the appropriate kagent resource based on API version + var kagentResource *unstructured.Unstructured + var gvk schema.GroupVersionKind + + if apiVersion == kagentAPIVersionV1Alpha2 { + kagentResource = r.createKagentRemoteMCPServerObject(mcpServer) + gvk = kagentRemoteMCPServerGVK + } else { + kagentResource = r.createKagentToolServerObject(mcpServer) + gvk = kagentToolServerGVK + } + + // Check if the kagent resource already exists + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(gvk) + err := r.Get(ctx, types.NamespacedName{ + Name: kagentResource.GetName(), + Namespace: kagentResource.GetNamespace(), + }, existing) + + if errors.IsNotFound(err) { + // Create the kagent resource + logger.Info("Creating kagent resource", + "kind", gvk.Kind, + "version", gvk.Version, + "name", kagentResource.GetName(), + "namespace", kagentResource.GetNamespace()) + if err := r.Create(ctx, kagentResource); err != nil { + return fmt.Errorf("failed to create kagent %s: %w", gvk.Kind, err) + } + return nil + } else if err != nil { + return fmt.Errorf("failed to get kagent %s: %w", gvk.Kind, err) + } + + // Update the kagent resource if needed + existingSpec, _, _ := unstructured.NestedMap(existing.Object, "spec") + desiredSpec, _, _ := unstructured.NestedMap(kagentResource.Object, "spec") + + if !equality.Semantic.DeepEqual(existingSpec, desiredSpec) { + logger.Info("Updating kagent resource", + "kind", gvk.Kind, + "version", gvk.Version, + "name", kagentResource.GetName(), + "namespace", kagentResource.GetNamespace()) + existing.Object["spec"] = kagentResource.Object["spec"] + if err := r.Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update kagent %s: %w", gvk.Kind, err) + } + } + + return nil +} + +// deleteKagentToolServer deletes any kagent resources (v1alpha1 or v1alpha2) if they exist +func (r *MCPServerReconciler) deleteKagentToolServer(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { + logger := log.FromContext(ctx) + resourceName := fmt.Sprintf("toolhive-%s", mcpServer.Name) + + // Try to delete v1alpha1 ToolServer + toolServer := &unstructured.Unstructured{} + toolServer.SetGroupVersionKind(kagentToolServerGVK) + toolServer.SetName(resourceName) + toolServer.SetNamespace(mcpServer.Namespace) + + err := r.Get(ctx, types.NamespacedName{ + Name: toolServer.GetName(), + Namespace: toolServer.GetNamespace(), + }, toolServer) + + if err == nil { + logger.Info("Deleting kagent ToolServer", + "name", toolServer.GetName(), + "namespace", toolServer.GetNamespace()) + if err := r.Delete(ctx, toolServer); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete kagent ToolServer: %w", err) + } + } + + // Try to delete v1alpha2 RemoteMCPServer + remoteMCPServer := &unstructured.Unstructured{} + remoteMCPServer.SetGroupVersionKind(kagentRemoteMCPServerGVK) + remoteMCPServer.SetName(resourceName) + remoteMCPServer.SetNamespace(mcpServer.Namespace) + + err = r.Get(ctx, types.NamespacedName{ + Name: remoteMCPServer.GetName(), + Namespace: remoteMCPServer.GetNamespace(), + }, remoteMCPServer) + + if err == nil { + logger.Info("Deleting kagent RemoteMCPServer", + "name", remoteMCPServer.GetName(), + "namespace", remoteMCPServer.GetNamespace()) + if err := r.Delete(ctx, remoteMCPServer); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete kagent RemoteMCPServer: %w", err) + } + } + + return nil +} + +// createKagentToolServerObject creates an unstructured kagent v1alpha1 ToolServer object +func (*MCPServerReconciler) createKagentToolServerObject(mcpServer *mcpv1alpha1.MCPServer) *unstructured.Unstructured { + kagentToolServer := &unstructured.Unstructured{} + kagentToolServer.SetGroupVersionKind(kagentToolServerGVK) + kagentToolServer.SetName(fmt.Sprintf("toolhive-%s", mcpServer.Name)) + kagentToolServer.SetNamespace(mcpServer.Namespace) + + // Build the service URL for the ToolHive MCP server + serviceName := createServiceName(mcpServer.Name) + serviceURL := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", + serviceName, mcpServer.Namespace, mcpServer.Spec.Port) + + // Determine the config type based on ToolHive transport + var configType string + var config map[string]interface{} + + switch mcpServer.Spec.Transport { + case "sse": + configType = kagentConfigTypeSSE + config = map[string]interface{}{ + kagentConfigTypeSSE: map[string]interface{}{ + "url": serviceURL, + }, + } + case "streamable-http": + configType = kagentConfigTypeStreamableHTTP + config = map[string]interface{}{ + kagentConfigTypeStreamableHTTP: map[string]interface{}{ + "url": serviceURL, + }, + } + default: + // For stdio or any other transport, default to SSE + // since ToolHive exposes everything via HTTP + configType = kagentConfigTypeSSE + config = map[string]interface{}{ + kagentConfigTypeSSE: map[string]interface{}{ + "url": serviceURL, + }, + } + } + + config["type"] = configType + + // Build the spec + spec := map[string]interface{}{ + "description": fmt.Sprintf("ToolHive MCP Server: %s", mcpServer.Name), + "config": config, + } + + kagentToolServer.Object = map[string]interface{}{ + "apiVersion": "kagent.dev/v1alpha1", + "kind": "ToolServer", + "metadata": map[string]interface{}{ + "name": kagentToolServer.GetName(), + "namespace": kagentToolServer.GetNamespace(), + "labels": map[string]interface{}{ + "toolhive.stacklok.dev/managed-by": "toolhive-operator", + "toolhive.stacklok.dev/mcpserver": mcpServer.Name, + }, + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "toolhive.stacklok.dev/v1alpha1", + "kind": "MCPServer", + "name": mcpServer.Name, + "uid": string(mcpServer.UID), + "controller": true, + "blockOwnerDeletion": true, + }, + }, + }, + "spec": spec, + } + + return kagentToolServer +} + +// createKagentRemoteMCPServerObject creates an unstructured kagent v1alpha2 RemoteMCPServer object +func (*MCPServerReconciler) createKagentRemoteMCPServerObject(mcpServer *mcpv1alpha1.MCPServer) *unstructured.Unstructured { + remoteMCPServer := &unstructured.Unstructured{} + remoteMCPServer.SetGroupVersionKind(kagentRemoteMCPServerGVK) + remoteMCPServer.SetName(fmt.Sprintf("toolhive-%s", mcpServer.Name)) + remoteMCPServer.SetNamespace(mcpServer.Namespace) + + // Build the service URL for the ToolHive MCP server + serviceName := createServiceName(mcpServer.Name) + serviceURL := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", + serviceName, mcpServer.Namespace, mcpServer.Spec.Port) + + // Determine the protocol based on ToolHive transport + var protocol string + switch mcpServer.Spec.Transport { + case "sse": + protocol = kagentProtocolSSE + case "streamable-http": + protocol = kagentProtocolStreamableHTTP + default: + // For stdio or any other transport, default to SSE + // since ToolHive exposes everything via HTTP + protocol = kagentProtocolSSE + } + + // Build the spec for v1alpha2 RemoteMCPServer + spec := map[string]interface{}{ + "description": fmt.Sprintf("ToolHive MCP Server: %s", mcpServer.Name), + "url": serviceURL, + "protocol": protocol, + // terminateOnClose defaults to true which is what we want + } + + // Add timeout if needed (optional, using default for now) + // spec["timeout"] = "30s" + + remoteMCPServer.Object = map[string]interface{}{ + "apiVersion": "kagent.dev/v1alpha2", + "kind": "RemoteMCPServer", + "metadata": map[string]interface{}{ + "name": remoteMCPServer.GetName(), + "namespace": remoteMCPServer.GetNamespace(), + "labels": map[string]interface{}{ + "toolhive.stacklok.dev/managed-by": "toolhive-operator", + "toolhive.stacklok.dev/mcpserver": mcpServer.Name, + }, + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "toolhive.stacklok.dev/v1alpha1", + "kind": "MCPServer", + "name": mcpServer.Name, + "uid": string(mcpServer.UID), + "controller": true, + "blockOwnerDeletion": true, + }, + }, + }, + "spec": spec, + } + + return remoteMCPServer +} diff --git a/cmd/thv-operator/controllers/mcpserver_kagent_test.go b/cmd/thv-operator/controllers/mcpserver_kagent_test.go new file mode 100644 index 000000000..1ddfc784b --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_kagent_test.go @@ -0,0 +1,489 @@ +package controllers + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +func TestIsKagentIntegrationEnabled(t *testing.T) { + t.Parallel() + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "enabled with true", + envValue: "true", + expected: true, + }, + { + name: "disabled with false", + envValue: "false", + expected: false, + }, + { + name: "disabled when empty", + envValue: "", + expected: false, + }, + { + name: "disabled with invalid value", + envValue: "invalid", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Set environment variable + os.Setenv("KAGENT_INTEGRATION_ENABLED", tt.envValue) + defer os.Unsetenv("KAGENT_INTEGRATION_ENABLED") + + // Test + result := isKagentIntegrationEnabled() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCreateKagentToolServerObject(t *testing.T) { + t.Parallel() + // Create a sample ToolHive MCPServer + toolhiveMCP := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test-namespace", + UID: "test-uid", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Transport: "sse", + Port: 8080, + TargetPort: 3000, + Args: []string{"--arg1", "--arg2"}, + Env: []mcpv1alpha1.EnvVar{ + {Name: "ENV1", Value: "value1"}, + {Name: "ENV2", Value: "value2"}, + }, + Secrets: []mcpv1alpha1.SecretRef{ + {Name: "secret1"}, + {Name: "secret2"}, + }, + }, + } + + // Create reconciler + r := &MCPServerReconciler{} + + // Create kagent ToolServer object + kagentToolServer := r.createKagentToolServerObject(toolhiveMCP) + + // Verify basic metadata + assert.Equal(t, "toolhive-test-mcp", kagentToolServer.GetName()) + assert.Equal(t, "test-namespace", kagentToolServer.GetNamespace()) + assert.Equal(t, "kagent.dev", kagentToolServer.GroupVersionKind().Group) + assert.Equal(t, "v1alpha1", kagentToolServer.GroupVersionKind().Version) + assert.Equal(t, "ToolServer", kagentToolServer.GroupVersionKind().Kind) + + // Verify labels + labels := kagentToolServer.GetLabels() + assert.Equal(t, "toolhive-operator", labels["toolhive.stacklok.dev/managed-by"]) + assert.Equal(t, "test-mcp", labels["toolhive.stacklok.dev/mcpserver"]) + + // Verify owner references + ownerRefs := kagentToolServer.GetOwnerReferences() + require.Len(t, ownerRefs, 1) + assert.Equal(t, "toolhive.stacklok.dev/v1alpha1", ownerRefs[0].APIVersion) + assert.Equal(t, "MCPServer", ownerRefs[0].Kind) + assert.Equal(t, "test-mcp", ownerRefs[0].Name) + assert.Equal(t, types.UID("test-uid"), ownerRefs[0].UID) + assert.True(t, *ownerRefs[0].Controller) + assert.True(t, *ownerRefs[0].BlockOwnerDeletion) + + // Verify spec + spec, found, err := unstructured.NestedMap(kagentToolServer.Object, "spec") + require.NoError(t, err) + require.True(t, found) + + // Verify description + description, found, err := unstructured.NestedString(spec, "description") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "ToolHive MCP Server: test-mcp", description) + + // Verify config + config, found, err := unstructured.NestedMap(spec, "config") + require.NoError(t, err) + require.True(t, found) + + // Verify config type (should be sse for sse transport) + configType, found, err := unstructured.NestedString(config, "type") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "sse", configType) + + // Verify SSE configuration + sseConfig, found, err := unstructured.NestedMap(config, "sse") + require.NoError(t, err) + require.True(t, found) + + url, found, err := unstructured.NestedString(sseConfig, "url") + require.NoError(t, err) + require.True(t, found) + expectedURL := fmt.Sprintf("http://mcp-%s-proxy.test-namespace.svc.cluster.local:8080", "test-mcp") + assert.Equal(t, expectedURL, url) +} + +func TestCreateKagentToolServerObjectStreamableHTTP(t *testing.T) { + t.Parallel() + // Create a sample ToolHive MCPServer with streamable-http transport + toolhiveMCP := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test-namespace", + UID: "test-uid", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Transport: "streamable-http", + Port: 8080, + }, + } + + // Create reconciler + r := &MCPServerReconciler{} + + // Create kagent ToolServer object + kagentToolServer := r.createKagentToolServerObject(toolhiveMCP) + + // Verify config + spec, found, err := unstructured.NestedMap(kagentToolServer.Object, "spec") + require.NoError(t, err) + require.True(t, found) + + config, found, err := unstructured.NestedMap(spec, "config") + require.NoError(t, err) + require.True(t, found) + + // Verify config type (should be streamableHttp for streamable-http transport) + configType, found, err := unstructured.NestedString(config, "type") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "streamableHttp", configType) + + // Verify streamableHttp configuration + streamableHttpConfig, found, err := unstructured.NestedMap(config, "streamableHttp") + require.NoError(t, err) + require.True(t, found) + + url, found, err := unstructured.NestedString(streamableHttpConfig, "url") + require.NoError(t, err) + require.True(t, found) + expectedURL := fmt.Sprintf("http://mcp-%s-proxy.test-namespace.svc.cluster.local:8080", "test-mcp") + assert.Equal(t, expectedURL, url) +} + +func TestEnsureKagentToolServer(t *testing.T) { //nolint:tparallel // Can't parallelize due to environment variable usage + t.Parallel() + // Create scheme with support for unstructured resources + scheme := runtime.NewScheme() + _ = mcpv1alpha1.AddToScheme(scheme) + + // Register kagent GVKs as unstructured types + // This allows the fake client to handle them even without the actual CRDs + // We need to add these to the scheme so the fake client knows about them + kagentToolServerGVK := schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha1", + Kind: "ToolServer", + } + kagentRemoteMCPServerGVK := schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha2", + Kind: "RemoteMCPServer", + } + + // Add the unstructured types to the scheme + scheme.AddKnownTypeWithName(kagentToolServerGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha1", + Kind: "ToolServerList", + }, &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(kagentRemoteMCPServerGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha2", + Kind: "RemoteMCPServerList", + }, &unstructured.UnstructuredList{}) + + // Create a sample ToolHive MCPServer + toolhiveMCP := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test-namespace", + UID: "test-uid", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Transport: "stdio", + Port: 8080, + }, + } + + t.Run("creates kagent ToolServer when enabled", func(t *testing.T) { //nolint:paralleltest // Can't parallelize due to environment variable usage + // Don't run in parallel as environment variables are shared + // Enable kagent integration + os.Setenv("KAGENT_INTEGRATION_ENABLED", "true") + defer os.Unsetenv("KAGENT_INTEGRATION_ENABLED") + + // Create fake client + client := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + // Create reconciler + r := &MCPServerReconciler{ + Client: client, + Scheme: scheme, + } + + // Ensure kagent ToolServer + err := r.ensureKagentToolServer(context.Background(), toolhiveMCP) + // The function should complete without error + // In a real cluster with kagent CRDs installed, this would create the resource + // With the fake client, it will try to create but may not be able to verify + require.NoError(t, err) + + // Try to get the created resource using the client + kagentToolServer := &unstructured.Unstructured{} + kagentToolServer.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha1", + Kind: "ToolServer", + }) + + // Try to get the resource - this may fail with fake client + // but the important part is that ensureKagentToolServer didn't error + err = client.Get(context.Background(), types.NamespacedName{ + Name: "toolhive-test-mcp", + Namespace: "test-namespace", + }, kagentToolServer) + + // With fake client and unregistered CRDs, we expect this to fail + // but that's OK - the main test is that ensureKagentToolServer works + if err == nil { + assert.Equal(t, "toolhive-test-mcp", kagentToolServer.GetName()) + assert.Equal(t, "test-namespace", kagentToolServer.GetNamespace()) + } + }) + + t.Run("deletes kagent ToolServer when disabled", func(t *testing.T) { //nolint:paralleltest // Can't parallelize due to environment variable usage + // Don't run in parallel as environment variables are shared + // Disable kagent integration + os.Setenv("KAGENT_INTEGRATION_ENABLED", "false") + defer os.Unsetenv("KAGENT_INTEGRATION_ENABLED") + + // Create existing kagent ToolServer + existingKagentToolServer := &unstructured.Unstructured{} + existingKagentToolServer.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha1", + Kind: "ToolServer", + }) + existingKagentToolServer.SetName("toolhive-test-mcp") + existingKagentToolServer.SetNamespace("test-namespace") + + // Create fake client with existing resource + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(existingKagentToolServer). + Build() + + // Create reconciler + r := &MCPServerReconciler{ + Client: client, + Scheme: scheme, + } + + // Ensure kagent ToolServer (should delete it) + err := r.ensureKagentToolServer(context.Background(), toolhiveMCP) + require.NoError(t, err) + + // Verify kagent ToolServer was deleted + kagentToolServer := &unstructured.Unstructured{} + kagentToolServer.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "kagent.dev", + Version: "v1alpha1", + Kind: "ToolServer", + }) + err = client.Get(context.Background(), types.NamespacedName{ + Name: "toolhive-test-mcp", + Namespace: "test-namespace", + }, kagentToolServer) + assert.True(t, errors.IsNotFound(err)) + }) +} + +// Test new v1alpha2 functions +func TestGetPreferredKagentAPIVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "v1alpha2 when set", + envValue: "v1alpha2", + expected: "v1alpha2", + }, + { + name: "v1alpha1 when set", + envValue: "v1alpha1", + expected: "v1alpha1", + }, + { + name: "default to v1alpha1 when empty", + envValue: "", + expected: "v1alpha1", + }, + { + name: "default to v1alpha1 when invalid", + envValue: "invalid", + expected: "v1alpha1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Set environment variable + os.Setenv("KAGENT_API_VERSION", tt.envValue) + defer os.Unsetenv("KAGENT_API_VERSION") + + // Test + result := getPreferredKagentAPIVersion() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCreateKagentRemoteMCPServerObject(t *testing.T) { + t.Parallel() + // Create a sample ToolHive MCPServer + toolhiveMCP := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test-namespace", + UID: "test-uid", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Transport: "sse", + Port: 8080, + }, + } + + // Create reconciler + r := &MCPServerReconciler{} + + // Create kagent RemoteMCPServer object + remoteMCPServer := r.createKagentRemoteMCPServerObject(toolhiveMCP) + + // Verify basic metadata + assert.Equal(t, "toolhive-test-mcp", remoteMCPServer.GetName()) + assert.Equal(t, "test-namespace", remoteMCPServer.GetNamespace()) + assert.Equal(t, "kagent.dev", remoteMCPServer.GroupVersionKind().Group) + assert.Equal(t, "v1alpha2", remoteMCPServer.GroupVersionKind().Version) + assert.Equal(t, "RemoteMCPServer", remoteMCPServer.GroupVersionKind().Kind) + + // Verify labels + labels := remoteMCPServer.GetLabels() + assert.Equal(t, "toolhive-operator", labels["toolhive.stacklok.dev/managed-by"]) + assert.Equal(t, "test-mcp", labels["toolhive.stacklok.dev/mcpserver"]) + + // Verify owner references + ownerRefs := remoteMCPServer.GetOwnerReferences() + require.Len(t, ownerRefs, 1) + assert.Equal(t, "toolhive.stacklok.dev/v1alpha1", ownerRefs[0].APIVersion) + assert.Equal(t, "MCPServer", ownerRefs[0].Kind) + assert.Equal(t, "test-mcp", ownerRefs[0].Name) + assert.Equal(t, types.UID("test-uid"), ownerRefs[0].UID) + assert.True(t, *ownerRefs[0].Controller) + assert.True(t, *ownerRefs[0].BlockOwnerDeletion) + + // Verify spec + spec, found, err := unstructured.NestedMap(remoteMCPServer.Object, "spec") + require.NoError(t, err) + require.True(t, found) + + // Verify description + description, found, err := unstructured.NestedString(spec, "description") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "ToolHive MCP Server: test-mcp", description) + + // Verify URL + url, found, err := unstructured.NestedString(spec, "url") + require.NoError(t, err) + require.True(t, found) + expectedURL := fmt.Sprintf("http://mcp-%s-proxy.test-namespace.svc.cluster.local:8080", "test-mcp") + assert.Equal(t, expectedURL, url) + + // Verify protocol (should be SSE for sse transport) + protocol, found, err := unstructured.NestedString(spec, "protocol") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "SSE", protocol) +} + +func TestCreateKagentRemoteMCPServerObjectStreamableHTTP(t *testing.T) { + t.Parallel() + // Create a sample ToolHive MCPServer with streamable-http transport + toolhiveMCP := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test-namespace", + UID: "test-uid", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Transport: "streamable-http", + Port: 8080, + }, + } + + // Create reconciler + r := &MCPServerReconciler{} + + // Create kagent RemoteMCPServer object + remoteMCPServer := r.createKagentRemoteMCPServerObject(toolhiveMCP) + + // Verify spec + spec, found, err := unstructured.NestedMap(remoteMCPServer.Object, "spec") + require.NoError(t, err) + require.True(t, found) + + // Verify protocol (should be STREAMABLE_HTTP for streamable-http transport) + protocol, found, err := unstructured.NestedString(spec, "protocol") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "STREAMABLE_HTTP", protocol) +} diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 864c27cb2..a90d302ad 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.2.18 +version: 0.2.19 appVersion: "0.3.5" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 92e9b1f8f..9748bff55 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,7 +1,6 @@ - # ToolHive Operator Helm Chart -![Version: 0.2.18](https://img.shields.io/badge/Version-0.2.18-informational?style=flat-square) +![Version: 0.2.19](https://img.shields.io/badge/Version-0.2.19-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -50,8 +49,10 @@ The command removes all the Kubernetes components associated with the chart and ## Values | Key | Type | Default | Description | -|-----|-------------|------|---------| +|-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | +| kagentIntegration | object | `{"enabled":false}` | Kagent integration configuration | +| kagentIntegration.enabled | bool | `false` | Enable kagent integration to create kagent MCPServer resources | | nameOverride | string | `""` | Override the name of the chart | | operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000},"env":{},"features":{"experimental":false},"image":"ghcr.io/stacklok/toolhive/operator:v0.3.5","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | diff --git a/deploy/charts/operator/templates/clusterrole/role.yaml b/deploy/charts/operator/templates/clusterrole/role.yaml index 75c2e08e5..f84546759 100644 --- a/deploy/charts/operator/templates/clusterrole/role.yaml +++ b/deploy/charts/operator/templates/clusterrole/role.yaml @@ -72,7 +72,70 @@ rules: - update - watch - apiGroups: - - apps + - toolhive.stacklok.dev + resources: + - mcpservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +# Allow the operator to update the finalizers of MCP Servers. +- apiGroups: + - toolhive.stacklok.dev + resources: + - mcpservers/finalizers + verbs: + - update +# Allow the operator to update and get the status of MCP Servers. +- apiGroups: + - toolhive.stacklok.dev + resources: + - mcpservers/status + verbs: + - get + - patch + - update +{{- if .Values.kagentIntegration.enabled }} +# Allow the operator to manage kagent resources when integration is enabled +# Supports both v1alpha1 (ToolServer) and v1alpha2 (RemoteMCPServer) +- apiGroups: + - kagent.dev + resources: + - toolservers + - remotemcpservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +{{- end }} + +# The below are permissions the operator needs in order to give to create further RBAC resources +# that are used by the ToolHive ProxyRunner and MCP server pods. +# Allow the operator to give permissions to create, delete, get, list, patch, update, and watch ServiceAccounts. +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + +# Allow the operator to give permissions to create, delete, get, list, patch, update, watch and apply StatefulSets. +- apiGroups: + - "apps" resources: - statefulsets verbs: diff --git a/deploy/charts/operator/templates/deployment.yaml b/deploy/charts/operator/templates/deployment.yaml index 069301edd..58b174521 100644 --- a/deploy/charts/operator/templates/deployment.yaml +++ b/deploy/charts/operator/templates/deployment.yaml @@ -58,6 +58,12 @@ spec: value: "{{ .Values.operator.proxyHost }}" - name: TOOLHIVE_REGISTRY_API_IMAGE value: "{{ .Values.registryAPI.image }}" + - name: KAGENT_INTEGRATION_ENABLED + value: "{{ .Values.kagentIntegration.enabled }}" + {{- if and .Values.kagentIntegration.enabled .Values.kagentIntegration.apiVersion }} + - name: KAGENT_API_VERSION + value: "{{ .Values.kagentIntegration.apiVersion }}" + {{- end }} {{- if .Values.operator.env }} {{- toYaml .Values.operator.env | nindent 10 }} {{- end }} diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 51438440e..f0ab1afd1 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -196,3 +196,10 @@ registryAPI: annotations: {} # -- Labels to add to the registry API service account labels: {} + +# -- Kagent integration configuration +kagentIntegration: + # -- Enable kagent integration to create kagent MCPServer resources + enabled: false + # -- Preferred kagent API version (v1alpha1 or v1alpha2). Defaults to v1alpha1 for backward compatibility + # apiVersion: v1alpha1 diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-pod-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-pod-running.yaml new file mode 100644 index 000000000..9821c66c3 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-pod-running.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: Pod +metadata: + namespace: toolhive-tenant-1 + labels: + app.kubernetes.io/name: kagent-tenant-1 + app.kubernetes.io/instance: kagent-tenant-1 + app.kubernetes.io/component: mcpserver +status: + phase: Running + conditions: + - type: Ready + status: "True" + - type: ContainersReady + status: "True" + - type: PodScheduled + status: "True" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kagent-tenant-1 + namespace: toolhive-tenant-1 + labels: + app.kubernetes.io/name: kagent-tenant-1 + app.kubernetes.io/instance: kagent-tenant-1 + app.kubernetes.io/component: mcpserver +status: + readyReplicas: 1 + replicas: 1 + conditions: + - type: Available + status: "True" + reason: MinimumReplicasAvailable + - type: Progressing + status: "True" + reason: NewReplicaSetAvailable +--- +apiVersion: v1 +kind: Service +metadata: + name: kagent-tenant-1 + namespace: toolhive-tenant-1 + labels: + app.kubernetes.io/name: kagent-tenant-1 + app.kubernetes.io/instance: kagent-tenant-1 + app.kubernetes.io/component: mcpserver +spec: + selector: + app.kubernetes.io/name: kagent-tenant-1 + app.kubernetes.io/instance: kagent-tenant-1 + app.kubernetes.io/component: mcpserver + ports: + - name: http + port: 8080 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-running.yaml new file mode 100644 index 000000000..4437bf9ca --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-1-running.yaml @@ -0,0 +1,44 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-tenant-1 + namespace: toolhive-tenant-1 +status: + conditions: + - type: Ready + status: "True" + reason: MCPServerReady + - type: DeploymentReady + status: "True" + reason: DeploymentReady + - type: ServiceReady + status: "True" + reason: ServiceReady + phase: Running +--- +# Verify ToolServer (v1alpha1) is created +apiVersion: kagent.dev/v1alpha1 +kind: ToolServer +metadata: + name: kagent-tenant-1 + namespace: toolhive-tenant-1 + ownerReferences: + - apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPServer + name: kagent-tenant-1 + controller: true + blockOwnerDeletion: true +spec: + transport: stdio + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + serviceAccount: proxyrunner-sa + env: + - name: TRANSPORT + value: stdio + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-pod-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-pod-running.yaml new file mode 100644 index 000000000..423c67bf9 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-pod-running.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: Pod +metadata: + namespace: toolhive-tenant-2 + labels: + app.kubernetes.io/name: kagent-tenant-2 + app.kubernetes.io/instance: kagent-tenant-2 + app.kubernetes.io/component: mcpserver +status: + phase: Running + conditions: + - type: Ready + status: "True" + - type: ContainersReady + status: "True" + - type: PodScheduled + status: "True" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kagent-tenant-2 + namespace: toolhive-tenant-2 + labels: + app.kubernetes.io/name: kagent-tenant-2 + app.kubernetes.io/instance: kagent-tenant-2 + app.kubernetes.io/component: mcpserver +status: + readyReplicas: 1 + replicas: 1 + conditions: + - type: Available + status: "True" + reason: MinimumReplicasAvailable + - type: Progressing + status: "True" + reason: NewReplicaSetAvailable +--- +apiVersion: v1 +kind: Service +metadata: + name: kagent-tenant-2 + namespace: toolhive-tenant-2 + labels: + app.kubernetes.io/name: kagent-tenant-2 + app.kubernetes.io/instance: kagent-tenant-2 + app.kubernetes.io/component: mcpserver +spec: + selector: + app.kubernetes.io/name: kagent-tenant-2 + app.kubernetes.io/instance: kagent-tenant-2 + app.kubernetes.io/component: mcpserver + ports: + - name: http + port: 8080 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-running.yaml new file mode 100644 index 000000000..5b2ee6cc0 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/assert-mcpserver-tenant-2-running.yaml @@ -0,0 +1,36 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-tenant-2 + namespace: toolhive-tenant-2 +status: + conditions: + - type: Ready + status: "True" + reason: MCPServerReady + - type: DeploymentReady + status: "True" + reason: DeploymentReady + - type: ServiceReady + status: "True" + reason: ServiceReady + phase: Running +--- +# Verify RemoteMCPServer (v1alpha2) is created for SSE transport +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: kagent-tenant-2 + namespace: toolhive-tenant-2 + ownerReferences: + - apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPServer + name: kagent-tenant-2 + controller: true + blockOwnerDeletion: true +spec: + transport: sse + url: http://kagent-tenant-2.toolhive-tenant-2.svc.cluster.local:8080 + env: + - name: TRANSPORT + value: sse \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml new file mode 100644 index 000000000..1b431c514 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml @@ -0,0 +1,266 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: kagent-integration-multi-tenant +spec: + description: Tests Kagent integration in multi-tenant environment + timeouts: + apply: 30s + assert: 60s + cleanup: 30s + exec: 300s + steps: + - name: verify-operator + description: Ensure operator is ready before testing + try: + - assert: + file: ../../setup/assert-operator-ready.yaml + + - name: enable-kagent-integration + description: Enable Kagent integration in operator + try: + - script: + content: | + echo "Enabling Kagent integration in operator..." + + # Update operator deployment to enable Kagent integration + kubectl patch deployment toolhive-operator -n toolhive-system --type='json' -p='[ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/env", + "value": [ + {"name": "UNSTRUCTURED_LOGS", "value": "false"}, + {"name": "ENABLE_EXPERIMENTAL_FEATURES", "value": "false"}, + {"name": "WATCH_NAMESPACE", "value": "toolhive-tenant-1,toolhive-tenant-2"}, + {"name": "TOOLHIVE_RUNNER_IMAGE", "value": "ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5"}, + {"name": "TOOLHIVE_PROXY_HOST", "value": "0.0.0.0"}, + {"name": "TOOLHIVE_REGISTRY_API_IMAGE", "value": "ghcr.io/stacklok/toolhive/registry-api:v0.3.5"}, + {"name": "KAGENT_INTEGRATION_ENABLED", "value": "true"}, + {"name": "KAGENT_API_VERSION", "value": "v1alpha2"} + ] + } + ]' + + # Wait for operator to restart + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + echo "✓ Kagent integration enabled for multi-tenant setup" + + - name: install-kagent-crds + description: Install Kagent CRDs for testing + try: + - script: + content: | + echo "Installing Kagent CRDs for testing..." + + # Create RemoteMCPServer CRD (v1alpha2) + cat </dev/null 2>&1 && echo "true" || echo "false") + TENANT2_EXISTS=$(kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-2 >/dev/null 2>&1 && echo "true" || echo "false") + + if [ "$TENANT1_EXISTS" = "true" ] && [ "$TENANT2_EXISTS" = "true" ]; then + echo "✓ RemoteMCPServers exist in both tenant namespaces" + break + fi + echo " Waiting for RemoteMCPServers... (attempt $i/30)" + sleep 2 + done + + # Verify tenant 1 RemoteMCPServer + if ! kubectl get remotemcpserver toolhive-kagent-tenant-1 -n toolhive-tenant-1 >/dev/null 2>&1; then + echo "✗ RemoteMCPServer was not created in toolhive-tenant-1" + exit 1 + fi + + # Verify tenant 2 RemoteMCPServer + if ! kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-2 >/dev/null 2>&1; then + echo "✗ RemoteMCPServer was not created in toolhive-tenant-2" + exit 1 + fi + + # Verify tenant 1 RemoteMCPServer spec + TENANT1_SPEC=$(kubectl get remotemcpserver toolhive-kagent-tenant-1 -n toolhive-tenant-1 -o json) + TENANT1_URL=$(echo "$TENANT1_SPEC" | jq -r '.spec.url // empty') + if [[ "$TENANT1_URL" == *"kagent-tenant-1-service.toolhive-tenant-1.svc.cluster.local:8080"* ]]; then + echo "✓ Tenant 1 RemoteMCPServer URL is correct: $TENANT1_URL" + else + echo "✗ Tenant 1 RemoteMCPServer URL is incorrect: $TENANT1_URL" + exit 1 + fi + + # Verify tenant 2 RemoteMCPServer spec + TENANT2_SPEC=$(kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-2 -o json) + TENANT2_URL=$(echo "$TENANT2_SPEC" | jq -r '.spec.url // empty') + if [[ "$TENANT2_URL" == *"kagent-tenant-2-service.toolhive-tenant-2.svc.cluster.local:8080"* ]]; then + echo "✓ Tenant 2 RemoteMCPServer URL is correct: $TENANT2_URL" + else + echo "✗ Tenant 2 RemoteMCPServer URL is incorrect: $TENANT2_URL" + exit 1 + fi + + # Verify namespace isolation - tenant 1 resource should not exist in tenant 2 namespace + if kubectl get remotemcpserver toolhive-kagent-tenant-1 -n toolhive-tenant-2 >/dev/null 2>&1; then + echo "✗ Tenant 1 RemoteMCPServer incorrectly exists in tenant 2 namespace" + exit 1 + else + echo "✓ Namespace isolation maintained - tenant 1 resource not in tenant 2 namespace" + fi + + # Verify namespace isolation - tenant 2 resource should not exist in tenant 1 namespace + if kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-1 >/dev/null 2>&1; then + echo "✗ Tenant 2 RemoteMCPServer incorrectly exists in tenant 1 namespace" + exit 1 + else + echo "✓ Namespace isolation maintained - tenant 2 resource not in tenant 1 namespace" + fi + + echo "✅ Multi-tenant Kagent integration test passed!" + + - name: test-cross-tenant-cleanup + description: Test that Kagent resources are cleaned up correctly per tenant + try: + - script: + content: | + echo "Testing cross-tenant cleanup..." + + # Delete tenant 1 MCPServer + kubectl delete mcpserver kagent-tenant-1 -n toolhive-tenant-1 --ignore-not-found=true + + # Wait for cleanup + sleep 10 + + # Verify tenant 1 RemoteMCPServer is cleaned up + if kubectl get remotemcpserver toolhive-kagent-tenant-1 -n toolhive-tenant-1 >/dev/null 2>&1; then + echo "✗ Tenant 1 RemoteMCPServer should be cleaned up when MCPServer is deleted" + exit 1 + else + echo "✓ Tenant 1 RemoteMCPServer cleaned up correctly" + fi + + # Verify tenant 2 RemoteMCPServer still exists + if ! kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-2 >/dev/null 2>&1; then + echo "✗ Tenant 2 RemoteMCPServer should still exist after tenant 1 cleanup" + exit 1 + else + echo "✓ Tenant 2 RemoteMCPServer correctly preserved" + fi + + # Clean up tenant 2 + kubectl delete mcpserver kagent-tenant-2 -n toolhive-tenant-2 --ignore-not-found=true + sleep 10 + + # Verify tenant 2 RemoteMCPServer is cleaned up + if kubectl get remotemcpserver toolhive-kagent-tenant-2 -n toolhive-tenant-2 >/dev/null 2>&1; then + echo "✗ Tenant 2 RemoteMCPServer should be cleaned up when MCPServer is deleted" + exit 1 + else + echo "✓ Tenant 2 RemoteMCPServer cleaned up correctly" + fi + + echo "✅ Cross-tenant cleanup test passed!" + + - name: disable-kagent-integration + description: Disable Kagent integration and cleanup + try: + - script: + content: | + echo "Disabling Kagent integration..." + + # Restore operator to disabled state + kubectl patch deployment toolhive-operator -n toolhive-system --type='json' -p='[ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/env", + "value": [ + {"name": "UNSTRUCTURED_LOGS", "value": "false"}, + {"name": "ENABLE_EXPERIMENTAL_FEATURES", "value": "false"}, + {"name": "WATCH_NAMESPACE", "value": "toolhive-tenant-1,toolhive-tenant-2"}, + {"name": "TOOLHIVE_RUNNER_IMAGE", "value": "ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5"}, + {"name": "TOOLHIVE_PROXY_HOST", "value": "0.0.0.0"}, + {"name": "TOOLHIVE_REGISTRY_API_IMAGE", "value": "ghcr.io/stacklok/toolhive/registry-api:v0.3.5"}, + {"name": "KAGENT_INTEGRATION_ENABLED", "value": "false"} + ] + } + ]' + + # Wait for operator to restart + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + # Clean up Kagent CRDs + kubectl delete crd remotemcpservers.kagent.dev --ignore-not-found=true + + echo "✓ Kagent integration disabled and cleaned up" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-1.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-1.yaml new file mode 100644 index 000000000..0bb753666 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-1.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-tenant-1 + namespace: toolhive-tenant-1 +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: sse + serviceAccount: proxyrunner-sa + env: + - name: TRANSPORT + value: sse + port: 8080 + targetPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-2.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-2.yaml new file mode 100644 index 000000000..f288dfdb4 --- /dev/null +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/kagent-integration/mcpserver-tenant-2.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-tenant-2 + namespace: toolhive-tenant-2 +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: sse + serviceAccount: proxyrunner-sa + env: + - name: TRANSPORT + value: sse + port: 8080 + targetPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-pod-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-pod-running.yaml new file mode 100644 index 000000000..90c5be116 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-pod-running.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Pod +metadata: + namespace: toolhive-system + labels: + toolhive.stacklok.dev/mcpserver: kagent-test +status: + phase: Running + (conditions[?type == 'Ready'] | [0].status): "True" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-running.yaml new file mode 100644 index 000000000..aa7b53516 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/assert-mcpserver-running.yaml @@ -0,0 +1,8 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-test + namespace: toolhive-system +status: + phase: Running + url: http://kagent-test-service.toolhive-system.svc.cluster.local:8080 \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml new file mode 100644 index 000000000..05a5b989a --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/chainsaw-test.yaml @@ -0,0 +1,463 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: kagent-integration +spec: + description: Tests Kagent integration - verifies ToolServer and RemoteMCPServer creation + timeouts: + apply: 30s + assert: 60s + cleanup: 30s + exec: 300s + steps: + - name: verify-operator + description: Ensure operator is ready before testing + try: + - assert: + file: ../../setup/assert-operator-ready.yaml + + - name: deploy-mcpserver-with-kagent-disabled + description: Deploy MCPServer with Kagent integration disabled (default) + try: + - apply: + file: ../common/mcp_serviceaccount.yaml + - assert: + file: ../common/mcp_serviceaccount.yaml + - apply: + file: mcpserver-kagent-disabled.yaml + - assert: + file: mcpserver-kagent-disabled.yaml + - assert: + file: assert-mcpserver-running.yaml + - assert: + file: assert-mcpserver-pod-running.yaml + # Verify no Kagent resources are created when integration is disabled + - script: + content: | + echo "Verifying no Kagent resources are created when integration is disabled..." + + # Check that no ToolServer exists + if kubectl get toolserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✗ ToolServer should not exist when Kagent integration is disabled" + exit 1 + else + echo "✓ No ToolServer created (expected when disabled)" + fi + + # Check that no RemoteMCPServer exists + if kubectl get remotemcpserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✗ RemoteMCPServer should not exist when Kagent integration is disabled" + exit 1 + else + echo "✓ No RemoteMCPServer created (expected when disabled)" + fi + + echo "✅ Kagent integration correctly disabled!" + + - name: enable-kagent-integration + description: Enable Kagent integration in operator + try: + - script: + content: | + echo "Enabling Kagent integration in operator..." + + # Update operator deployment to enable Kagent integration + kubectl patch deployment toolhive-operator -n toolhive-system --type='json' -p='[ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/env", + "value": [ + {"name": "UNSTRUCTURED_LOGS", "value": "false"}, + {"name": "ENABLE_EXPERIMENTAL_FEATURES", "value": "false"}, + {"name": "TOOLHIVE_RUNNER_IMAGE", "value": "ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5"}, + {"name": "TOOLHIVE_PROXY_HOST", "value": "0.0.0.0"}, + {"name": "TOOLHIVE_REGISTRY_API_IMAGE", "value": "ghcr.io/stacklok/toolhive/registry-api:v0.3.5"}, + {"name": "KAGENT_INTEGRATION_ENABLED", "value": "true"}, + {"name": "KAGENT_API_VERSION", "value": "v1alpha1"} + ] + } + ]' + + # Wait for operator to restart + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + echo "✓ Kagent integration enabled" + + - name: install-kagent-crds + description: Install Kagent CRDs for testing + try: + - script: + content: | + echo "Installing Kagent CRDs for testing..." + + # Create ToolServer CRD (v1alpha1) + cat </dev/null 2>&1; then + echo "✓ ToolServer toolhive-kagent-test exists" + break + fi + echo " Waiting for ToolServer... (attempt $i/30)" + sleep 2 + done + + # Verify ToolServer exists + if ! kubectl get toolserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✗ ToolServer was not created" + kubectl get toolserver -A + exit 1 + fi + + # Verify ToolServer spec + TOOLSERVER_SPEC=$(kubectl get toolserver toolhive-kagent-test -n toolhive-system -o json) + + # Check description + DESCRIPTION=$(echo "$TOOLSERVER_SPEC" | jq -r '.spec.description // empty') + if [[ "$DESCRIPTION" == *"ToolHive MCP Server: kagent-test"* ]]; then + echo "✓ ToolServer description is correct" + else + echo "✗ ToolServer description is incorrect: $DESCRIPTION" + exit 1 + fi + + # Check config type + CONFIG_TYPE=$(echo "$TOOLSERVER_SPEC" | jq -r '.spec.config.type // empty') + if [ "$CONFIG_TYPE" = "sse" ]; then + echo "✓ ToolServer config type is 'sse'" + else + echo "✗ ToolServer config type is '$CONFIG_TYPE', expected 'sse'" + exit 1 + fi + + # Check SSE URL + SSE_URL=$(echo "$TOOLSERVER_SPEC" | jq -r '.spec.config.sse.url // empty') + if [[ "$SSE_URL" == *"kagent-test-service.toolhive-system.svc.cluster.local:8080"* ]]; then + echo "✓ ToolServer SSE URL is correct: $SSE_URL" + else + echo "✗ ToolServer SSE URL is incorrect: $SSE_URL" + exit 1 + fi + + # Check owner references + OWNER_KIND=$(echo "$TOOLSERVER_SPEC" | jq -r '.metadata.ownerReferences[0].kind // empty') + OWNER_NAME=$(echo "$TOOLSERVER_SPEC" | jq -r '.metadata.ownerReferences[0].name // empty') + if [ "$OWNER_KIND" = "MCPServer" ] && [ "$OWNER_NAME" = "kagent-test" ]; then + echo "✓ ToolServer has correct owner reference" + else + echo "✗ ToolServer owner reference is incorrect: kind=$OWNER_KIND, name=$OWNER_NAME" + exit 1 + fi + + # Check labels + MANAGED_BY=$(echo "$TOOLSERVER_SPEC" | jq -r '.metadata.labels."toolhive.stacklok.dev/managed-by" // empty') + MCPSERVER_LABEL=$(echo "$TOOLSERVER_SPEC" | jq -r '.metadata.labels."toolhive.stacklok.dev/mcpserver" // empty') + if [ "$MANAGED_BY" = "toolhive-operator" ] && [ "$MCPSERVER_LABEL" = "kagent-test" ]; then + echo "✓ ToolServer has correct labels" + else + echo "✗ ToolServer labels are incorrect: managed-by=$MANAGED_BY, mcpserver=$MCPSERVER_LABEL" + exit 1 + fi + + echo "✅ ToolServer (v1alpha1) integration test passed!" + + - name: test-kagent-v1alpha2-integration + description: Test Kagent v1alpha2 RemoteMCPServer integration + try: + # Update operator to prefer v1alpha2 + - script: + content: | + echo "Updating operator to prefer Kagent v1alpha2..." + + kubectl patch deployment toolhive-operator -n toolhive-system --type='json' -p='[ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/env", + "value": [ + {"name": "UNSTRUCTURED_LOGS", "value": "false"}, + {"name": "ENABLE_EXPERIMENTAL_FEATURES", "value": "false"}, + {"name": "TOOLHIVE_RUNNER_IMAGE", "value": "ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5"}, + {"name": "TOOLHIVE_PROXY_HOST", "value": "0.0.0.0"}, + {"name": "TOOLHIVE_REGISTRY_API_IMAGE", "value": "ghcr.io/stacklok/toolhive/registry-api:v0.3.5"}, + {"name": "KAGENT_INTEGRATION_ENABLED", "value": "true"}, + {"name": "KAGENT_API_VERSION", "value": "v1alpha2"} + ] + } + ]' + + # Wait for operator to restart + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + echo "✓ Operator updated to prefer v1alpha2" + # Delete and recreate MCPServer to trigger v1alpha2 creation + - script: + content: | + echo "Recreating MCPServer to trigger v1alpha2 resource creation..." + kubectl delete mcpserver kagent-test -n toolhive-system --ignore-not-found=true + sleep 5 + - apply: + file: mcpserver-kagent-enabled.yaml + - assert: + file: mcpserver-kagent-enabled.yaml + - assert: + file: assert-mcpserver-running.yaml + - assert: + file: assert-mcpserver-pod-running.yaml + # Verify RemoteMCPServer is created + - script: + content: | + echo "Verifying RemoteMCPServer (v1alpha2) creation..." + + # Wait for RemoteMCPServer to be created + for i in $(seq 1 30); do + if kubectl get remotemcpserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✓ RemoteMCPServer toolhive-kagent-test exists" + break + fi + echo " Waiting for RemoteMCPServer... (attempt $i/30)" + sleep 2 + done + + # Verify RemoteMCPServer exists + if ! kubectl get remotemcpserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✗ RemoteMCPServer was not created" + kubectl get remotemcpserver -A + exit 1 + fi + + # Verify RemoteMCPServer spec + REMOTEMCP_SPEC=$(kubectl get remotemcpserver toolhive-kagent-test -n toolhive-system -o json) + + # Check description + DESCRIPTION=$(echo "$REMOTEMCP_SPEC" | jq -r '.spec.description // empty') + if [[ "$DESCRIPTION" == *"ToolHive MCP Server: kagent-test"* ]]; then + echo "✓ RemoteMCPServer description is correct" + else + echo "✗ RemoteMCPServer description is incorrect: $DESCRIPTION" + exit 1 + fi + + # Check protocol + PROTOCOL=$(echo "$REMOTEMCP_SPEC" | jq -r '.spec.protocol // empty') + if [ "$PROTOCOL" = "SSE" ]; then + echo "✓ RemoteMCPServer protocol is 'SSE'" + else + echo "✗ RemoteMCPServer protocol is '$PROTOCOL', expected 'SSE'" + exit 1 + fi + + # Check URL + URL=$(echo "$REMOTEMCP_SPEC" | jq -r '.spec.url // empty') + if [[ "$URL" == *"kagent-test-service.toolhive-system.svc.cluster.local:8080"* ]]; then + echo "✓ RemoteMCPServer URL is correct: $URL" + else + echo "✗ RemoteMCPServer URL is incorrect: $URL" + exit 1 + fi + + # Check owner references + OWNER_KIND=$(echo "$REMOTEMCP_SPEC" | jq -r '.metadata.ownerReferences[0].kind // empty') + OWNER_NAME=$(echo "$REMOTEMCP_SPEC" | jq -r '.metadata.ownerReferences[0].name // empty') + if [ "$OWNER_KIND" = "MCPServer" ] && [ "$OWNER_NAME" = "kagent-test" ]; then + echo "✓ RemoteMCPServer has correct owner reference" + else + echo "✗ RemoteMCPServer owner reference is incorrect: kind=$OWNER_KIND, name=$OWNER_NAME" + exit 1 + fi + + # Check labels + MANAGED_BY=$(echo "$REMOTEMCP_SPEC" | jq -r '.metadata.labels."toolhive.stacklok.dev/managed-by" // empty') + MCPSERVER_LABEL=$(echo "$REMOTEMCP_SPEC" | jq -r '.metadata.labels."toolhive.stacklok.dev/mcpserver" // empty') + if [ "$MANAGED_BY" = "toolhive-operator" ] && [ "$MCPSERVER_LABEL" = "kagent-test" ]; then + echo "✓ RemoteMCPServer has correct labels" + else + echo "✗ RemoteMCPServer labels are incorrect: managed-by=$MANAGED_BY, mcpserver=$MCPSERVER_LABEL" + exit 1 + fi + + echo "✅ RemoteMCPServer (v1alpha2) integration test passed!" + + - name: test-transport-mapping + description: Test different transport mode mappings + try: + # Test streamable-http transport + - apply: + file: mcpserver-streamable-http.yaml + - assert: + file: mcpserver-streamable-http.yaml + - script: + content: | + echo "Testing streamable-http transport mapping..." + + # Wait for RemoteMCPServer to be created/updated + sleep 10 + + # Verify protocol mapping for streamable-http + PROTOCOL=$(kubectl get remotemcpserver toolhive-kagent-streamable-http -n toolhive-system -o jsonpath='{.spec.protocol}' 2>/dev/null || echo "") + if [ "$PROTOCOL" = "STREAMABLE_HTTP" ]; then + echo "✓ streamable-http correctly mapped to STREAMABLE_HTTP protocol" + else + echo "✗ streamable-http mapping failed: got '$PROTOCOL', expected 'STREAMABLE_HTTP'" + exit 1 + fi + + echo "✅ Transport mapping test passed!" + + - name: test-kagent-cleanup + description: Test Kagent resource cleanup when MCPServer is deleted + try: + - script: + content: | + echo "Testing Kagent resource cleanup..." + + # Delete MCPServer + kubectl delete mcpserver kagent-test -n toolhive-system --ignore-not-found=true + kubectl delete mcpserver kagent-streamable-http -n toolhive-system --ignore-not-found=true + + # Wait for cleanup + sleep 10 + + # Verify RemoteMCPServer is cleaned up + if kubectl get remotemcpserver toolhive-kagent-test -n toolhive-system >/dev/null 2>&1; then + echo "✗ RemoteMCPServer should be cleaned up when MCPServer is deleted" + exit 1 + else + echo "✓ RemoteMCPServer cleaned up correctly" + fi + + if kubectl get remotemcpserver toolhive-kagent-streamable-http -n toolhive-system >/dev/null 2>&1; then + echo "✗ RemoteMCPServer (streamable-http) should be cleaned up when MCPServer is deleted" + exit 1 + else + echo "✓ RemoteMCPServer (streamable-http) cleaned up correctly" + fi + + echo "✅ Kagent resource cleanup test passed!" + + - name: disable-kagent-integration + description: Disable Kagent integration and cleanup + try: + - script: + content: | + echo "Disabling Kagent integration..." + + # Restore operator to disabled state + kubectl patch deployment toolhive-operator -n toolhive-system --type='json' -p='[ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/env", + "value": [ + {"name": "UNSTRUCTURED_LOGS", "value": "false"}, + {"name": "ENABLE_EXPERIMENTAL_FEATURES", "value": "false"}, + {"name": "TOOLHIVE_RUNNER_IMAGE", "value": "ghcr.io/stacklok/toolhive/proxyrunner:v0.3.5"}, + {"name": "TOOLHIVE_PROXY_HOST", "value": "0.0.0.0"}, + {"name": "TOOLHIVE_REGISTRY_API_IMAGE", "value": "ghcr.io/stacklok/toolhive/registry-api:v0.3.5"}, + {"name": "KAGENT_INTEGRATION_ENABLED", "value": "false"} + ] + } + ]' + + # Wait for operator to restart + kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s + + # Clean up Kagent CRDs + kubectl delete crd toolservers.kagent.dev --ignore-not-found=true + kubectl delete crd remotemcpservers.kagent.dev --ignore-not-found=true + + echo "✓ Kagent integration disabled and cleaned up" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-disabled.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-disabled.yaml new file mode 100644 index 000000000..55cd2bb59 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-disabled.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-test + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: sse + serviceAccount: test-mcp-sa + env: + - name: TRANSPORT + value: sse + port: 8080 + targetPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-enabled.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-enabled.yaml new file mode 100644 index 000000000..55cd2bb59 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-kagent-enabled.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-test + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: sse + serviceAccount: test-mcp-sa + env: + - name: TRANSPORT + value: sse + port: 8080 + targetPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-streamable-http.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-streamable-http.yaml new file mode 100644 index 000000000..fcf9646b6 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/kagent-integration/mcpserver-streamable-http.yaml @@ -0,0 +1,24 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: kagent-streamable-http + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 + transport: streamable-http + serviceAccount: test-mcp-sa + env: + - name: TRANSPORT + value: streamable-http + port: 8080 + targetPort: 8080 + permissionProfile: + type: builtin + name: network + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" \ No newline at end of file