From 17ef19cf87b6260591938a9c2bc8a75737eaa78f Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:57:27 +0100 Subject: [PATCH] Decouple VirtualMCPServer CRD from vmcp config VirtualMCPServerSpec.Config was typed as pkg/vmcp/config.Config, so controller-gen walked the entire internal config tree into the public CRD schema. Any change to the internal on-disk/runtime config model (field, tag, validation) therefore leaked into the v1beta1 CRD. Introduce an operator-owned mirror, cmd/thv-operator/pkg/vmcpcrd, a field-for-field duplicate of pkg/vmcp/config (incl. Duration and the composite-tool validation). Retype VirtualMCPServerSpec.Config and the VirtualMCPCompositeToolDefinition embed onto the mirror, and convert mirror -> config.Config in the operator's converter via a JSON transcode (crdToRuntime), keeping the existing Kubernetes-resolution overrides. The no-leak guarantee is now structural: nothing reachable from the CRD types references pkg/vmcp/config, so internal config changes cannot reach the CRD schema. Generated CRD manifests are byte-identical. Tests: - structural parity (config <-> mirror JSON leaf-set equality) - round-trip transcode fuzz (randfill) - categorical no-leak boundary (no CRD field type in pkg/vmcp/config) Scope note: external shared types embedded in config (telemetry, audit, ratelimit, auth strategy) are not yet mirrored; that and the crd-ref-docs rendering of the mirror are tracked follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/v1beta1/mcpserver_types_test.go | 4 +- .../v1beta1/v1beta1test/virtualmcpserver.go | 22 +- ...virtualmcpcompositetooldefinition_types.go | 12 +- .../api/v1beta1/virtualmcpserver_types.go | 8 +- .../v1beta1/virtualmcpserver_types_test.go | 38 +- .../virtualmcpserver_controller_test.go | 6 +- .../virtualmcpserver_deployment_test.go | 5 +- .../virtualmcpserver_vmcpconfig_test.go | 87 +- .../virtualmcpserver_watch_test.go | 56 +- cmd/thv-operator/pkg/vmcpconfig/converter.go | 92 +- .../pkg/vmcpconfig/converter_test.go | 143 +-- .../pkg/vmcpcrd/composite_validation.go | 741 ++++++++++++++ cmd/thv-operator/pkg/vmcpcrd/noleak_test.go | 115 +++ cmd/thv-operator/pkg/vmcpcrd/parity_test.go | 72 ++ .../pkg/vmcpcrd/roundtrip_test.go | 132 +++ .../pkg/vmcpcrd/vmcpconfig_types.go | 952 ++++++++++++++++++ .../pkg/vmcpcrd/zz_generated.deepcopy.go | 687 +++++++++++++ ...onfig_virtualmcpserver_integration_test.go | 11 +- .../virtualmcpserver_authzconfig_cel_test.go | 4 +- ...pserver_authzconfigref_integration_test.go | 3 +- ...rtualmcpserver_compositetool_watch_test.go | 28 +- ...lmcpserver_elicitation_integration_test.go | 60 +- ...irtualmcpserver_externalauth_watch_test.go | 4 +- ...erver_imagepullsecrets_integration_test.go | 8 +- ...server_podtemplatespec_integration_test.go | 8 +- ...tualmcpserver_replicas_integration_test.go | 6 +- ...irtualmcpserver_sessionstorage_cel_test.go | 4 +- ...server_telemetryconfig_integration_test.go | 7 +- docs/operator/crd-api.md | 46 +- go.mod | 2 +- pkg/vmcp/status/k8s_reporter_test.go | 4 +- .../virtualmcp_aggregation_filtering_test.go | 12 +- .../virtualmcp_aggregation_overrides_test.go | 10 +- .../virtualmcp_auth_discovery_test.go | 6 +- .../virtualmcp_circuit_breaker_test.go | 18 +- ...irtualmcp_composite_defaultresults_test.go | 16 +- .../virtualmcp_composite_hidden_tools_test.go | 16 +- .../virtualmcp_composite_parallel_test.go | 12 +- .../virtualmcp_composite_referenced_test.go | 14 +- .../virtualmcp_composite_sequential_test.go | 12 +- .../virtualmcp_composite_validation_test.go | 14 +- .../virtualmcp_conflict_resolution_test.go | 26 +- .../virtualmcp_discovered_mode_test.go | 6 +- .../virtualmcp_excludeall_global_test.go | 6 +- .../virtualmcp_external_auth_test.go | 22 +- ...rtualmcp_optimizer_circuit_breaker_test.go | 20 +- .../virtualmcp_optimizer_composite_test.go | 16 +- .../virtualmcp_optimizer_multibackend_test.go | 10 +- .../virtualmcp/virtualmcp_optimizer_test.go | 16 +- .../virtualmcp_rate_limiting_test.go | 4 +- .../virtualmcp_session_management_test.go | 6 +- .../virtualmcp/virtualmcp_telemetry_test.go | 3 +- .../virtualmcp/virtualmcp_toolconfig_test.go | 20 +- .../virtualmcp_yardstick_base_test.go | 6 +- 54 files changed, 3205 insertions(+), 453 deletions(-) create mode 100644 cmd/thv-operator/pkg/vmcpcrd/composite_validation.go create mode 100644 cmd/thv-operator/pkg/vmcpcrd/noleak_test.go create mode 100644 cmd/thv-operator/pkg/vmcpcrd/parity_test.go create mode 100644 cmd/thv-operator/pkg/vmcpcrd/roundtrip_test.go create mode 100644 cmd/thv-operator/pkg/vmcpcrd/vmcpconfig_types.go create mode 100644 cmd/thv-operator/pkg/vmcpcrd/zz_generated.deepcopy.go diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go index eea810c4a3..2f6a9a3285 100644 --- a/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) func TestSessionStorageConfigJSONRoundtrip(t *testing.T) { @@ -128,7 +128,7 @@ func TestVirtualMCPServerSpecRateLimitingJSONRoundtrip(t *testing.T) { Provider: "redis", Address: "redis.default.svc.cluster.local:6379", }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ RateLimiting: &RateLimitConfig{ Shared: &RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, PerUser: &RateLimitBucket{ diff --git a/cmd/thv-operator/api/v1beta1/v1beta1test/virtualmcpserver.go b/cmd/thv-operator/api/v1beta1/v1beta1test/virtualmcpserver.go index 77a0eec1b0..9e83d33a60 100644 --- a/cmd/thv-operator/api/v1beta1/v1beta1test/virtualmcpserver.go +++ b/cmd/thv-operator/api/v1beta1/v1beta1test/virtualmcpserver.go @@ -4,10 +4,13 @@ package v1beta1test import ( + "encoding/json" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -42,8 +45,25 @@ func WithVMCPGroupRef(name string) VirtualMCPServerOption { } // WithVMCPConfig sets the vMCP server configuration. +// +// The CRD spec field is the operator-owned vmcpcrd.Config mirror, but tests +// author fixtures using the runtime config.Config model. Because the two are +// field-for-field identical (enforced by the AssertNoDrift + round-trip tests), +// this option transcodes the runtime config into the CRD mirror via JSON. Any +// transcode error indicates a real drift between the two schemas and is fatal to +// the test rather than silently dropped. func WithVMCPConfig(cfg config.Config) VirtualMCPServerOption { - return func(v *mcpv1beta1.VirtualMCPServer) { v.Spec.Config = cfg } + return func(v *mcpv1beta1.VirtualMCPServer) { + data, err := json.Marshal(cfg) + if err != nil { + panic("v1beta1test: marshal config.Config: " + err.Error()) + } + var mirror vmcpcrd.Config + if err := json.Unmarshal(data, &mirror); err != nil { + panic("v1beta1test: unmarshal into vmcpcrd.Config: " + err.Error()) + } + v.Spec.Config = mirror + } } // WithVMCPIncomingAuth sets the incoming auth configuration. diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go index 7c647c6c37..ba2febae30 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go @@ -6,14 +6,17 @@ package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) // VirtualMCPCompositeToolDefinitionSpec defines the desired state of VirtualMCPCompositeToolDefinition. // This embeds the CompositeToolConfig from pkg/vmcp/config to share the configuration model // between CLI and operator usage. type VirtualMCPCompositeToolDefinitionSpec struct { - config.CompositeToolConfig `json:",inline"` // nolint:revive // inline is valid + // The embedded type is the operator-owned vmcpcrd.CompositeToolConfig mirror (a field-for-field + // duplicate of pkg/vmcp/config.CompositeToolConfig) so the CRD schema is generated solely from + // operator-owned types. See the vmcpcrd package doc for the decoupling rationale. + vmcpcrd.CompositeToolConfig `json:",inline"` // nolint:revive // inline is valid } // VirtualMCPCompositeToolDefinitionStatus defines the observed state of VirtualMCPCompositeToolDefinition @@ -129,9 +132,10 @@ type VirtualMCPCompositeToolDefinitionList struct { // Validate performs validation for VirtualMCPCompositeToolDefinition // This method is called by the controller during reconciliation -// It delegates to the shared ValidateCompositeToolConfig in pkg/vmcp/config +// It delegates to the operator-owned ValidateCompositeToolConfig in the vmcpcrd mirror, +// kept behaviourally in lock-step with pkg/vmcp/config. func (r *VirtualMCPCompositeToolDefinition) Validate() error { - return config.ValidateCompositeToolConfig("spec", &r.Spec.CompositeToolConfig) + return vmcpcrd.ValidateCompositeToolConfig("spec", &r.Spec.CompositeToolConfig) } // GetValidationErrors returns a list of validation errors diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index 32e9c795f9..a88780748a 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -10,8 +10,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" - "github.com/stacklok/toolhive/pkg/vmcp/config" ) // VirtualMCPServerSpec defines the desired state of VirtualMCPServer @@ -83,7 +83,7 @@ type VirtualMCPServerSpec struct { // Config is the Virtual MCP server configuration. // The audit config from here is also supported, but not required. // +optional - Config config.Config `json:"config,omitempty"` + Config vmcpcrd.Config `json:"config,omitempty"` // TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. // The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. @@ -623,7 +623,7 @@ func (r *VirtualMCPServer) validateEmbeddingServer() error { // optimizer with default values so the embedding server is actually used. // The controller emits a Kubernetes event for this case. if hasRef && !hasOptimizer { - r.Spec.Config.Optimizer = &config.OptimizerConfig{} + r.Spec.Config.Optimizer = &vmcpcrd.OptimizerConfig{} } return nil @@ -726,7 +726,7 @@ func (r *VirtualMCPServer) validateCompositeTools() error { toolNames[tool.Name] = true // Use shared validation - if err := config.ValidateCompositeToolConfig( + if err := vmcpcrd.ValidateCompositeToolConfig( fmt.Sprintf("spec.config.compositeTools[%d]", i), tool, ); err != nil { return err diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go index 466400709c..756eac0533 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcp "github.com/stacklok/toolhive/pkg/vmcp" - "github.com/stacklok/toolhive/pkg/vmcp/config" ) func TestVirtualMCPServerPhaseTransitions(t *testing.T) { @@ -147,8 +147,8 @@ func TestVirtualMCPServerDefaultValues(t *testing.T) { }, Spec: VirtualMCPServerSpec{ GroupRef: &MCPGroupRef{Name: "test-group"}, - Config: config.Config{ - Aggregation: &config.AggregationConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "", // Should default to "prefix" }, }, @@ -205,13 +205,13 @@ func TestConflictResolutionStrategies(t *testing.T) { tests := []struct { name string strategy vmcp.ConflictResolutionStrategy - configValue *config.ConflictResolutionConfig + configValue *vmcpcrd.ConflictResolutionConfig isValid bool }{ { name: "prefix_strategy_with_format", strategy: vmcp.ConflictStrategyPrefix, - configValue: &config.ConflictResolutionConfig{ + configValue: &vmcpcrd.ConflictResolutionConfig{ PrefixFormat: "{workload}_", }, isValid: true, @@ -219,7 +219,7 @@ func TestConflictResolutionStrategies(t *testing.T) { { name: "priority_strategy_with_order", strategy: vmcp.ConflictStrategyPriority, - configValue: &config.ConflictResolutionConfig{ + configValue: &vmcpcrd.ConflictResolutionConfig{ PriorityOrder: []string{"github", "jira", "slack"}, }, isValid: true, @@ -227,7 +227,7 @@ func TestConflictResolutionStrategies(t *testing.T) { { name: "manual_strategy", strategy: vmcp.ConflictStrategyManual, - configValue: &config.ConflictResolutionConfig{}, + configValue: &vmcpcrd.ConflictResolutionConfig{}, isValid: true, }, } @@ -239,8 +239,8 @@ func TestConflictResolutionStrategies(t *testing.T) { vmcpServer := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ GroupRef: &MCPGroupRef{Name: "test-group"}, - Config: config.Config{ - Aggregation: &config.AggregationConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: tt.strategy, ConflictResolutionConfig: tt.configValue, }, @@ -320,13 +320,13 @@ func TestCompositeToolStepDependencies(t *testing.T) { tests := []struct { name string - steps []config.WorkflowStepConfig + steps []vmcpcrd.WorkflowStepConfig isValid bool errMsg string }{ { name: "valid_sequential_dependencies", - steps: []config.WorkflowStepConfig{ + steps: []vmcpcrd.WorkflowStepConfig{ {ID: "step1", Type: "tool", Tool: "backend.tool1"}, {ID: "step2", Type: "tool", Tool: "backend.tool2", DependsOn: []string{"step1"}}, {ID: "step3", Type: "tool", Tool: "backend.tool3", DependsOn: []string{"step2"}}, @@ -335,7 +335,7 @@ func TestCompositeToolStepDependencies(t *testing.T) { }, { name: "valid_parallel_steps", - steps: []config.WorkflowStepConfig{ + steps: []vmcpcrd.WorkflowStepConfig{ {ID: "step1", Type: "tool", Tool: "backend.tool1"}, {ID: "step2", Type: "tool", Tool: "backend.tool2"}, {ID: "step3", Type: "tool", Tool: "backend.tool3", DependsOn: []string{"step1", "step2"}}, @@ -344,7 +344,7 @@ func TestCompositeToolStepDependencies(t *testing.T) { }, { name: "valid_forward_reference", - steps: []config.WorkflowStepConfig{ + steps: []vmcpcrd.WorkflowStepConfig{ {ID: "step1", Type: "tool", Tool: "backend.tool1", DependsOn: []string{"step2"}}, {ID: "step2", Type: "tool", Tool: "backend.tool2"}, }, @@ -359,8 +359,8 @@ func TestCompositeToolStepDependencies(t *testing.T) { server := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ GroupRef: &MCPGroupRef{Name: "test-group"}, - Config: config.Config{ - CompositeTools: []config.CompositeToolConfig{ + Config: vmcpcrd.Config{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "test-workflow", Description: "Test workflow", @@ -411,8 +411,8 @@ func TestValidateEmbeddingServer(t *testing.T) { server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ GroupRef: &MCPGroupRef{Name: "test-group"}, - Config: config.Config{ - Optimizer: &config.OptimizerConfig{}, + Config: vmcpcrd.Config{ + Optimizer: &vmcpcrd.OptimizerConfig{}, }, EmbeddingServerRef: &EmbeddingServerRef{ Name: "my-embedding", @@ -426,8 +426,8 @@ func TestValidateEmbeddingServer(t *testing.T) { server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ GroupRef: &MCPGroupRef{Name: "test-group"}, - Config: config.Config{ - Optimizer: &config.OptimizerConfig{}, + Config: vmcpcrd.Config{ + Optimizer: &vmcpcrd.OptimizerConfig{}, }, }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index f7afec82a8..5f77686dd1 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -39,7 +39,7 @@ import ( ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -1802,9 +1802,9 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: testGroupName, - Operational: &vmcpconfig.OperationalConfig{ + Operational: &vmcpcrd.OperationalConfig{ LogLevel: "debug", }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 056e4a7928..995cd9aa7f 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -33,6 +33,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -170,8 +171,8 @@ func TestBuildContainerArgsForVmcp(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - Operational: &vmcpconfig.OperationalConfig{ + Config: vmcpcrd.Config{ + Operational: &vmcpcrd.OperationalConfig{ LogLevel: "debug", }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index e32410d418..62aa285572 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -28,6 +28,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" statusmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks" vmcpconfigconv "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpconfig" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -272,7 +273,7 @@ func TestConvertAggregation(t *testing.T) { tests := []struct { name string - aggregation *vmcpconfig.AggregationConfig + aggregation *vmcpcrd.AggregationConfig expectedStrategy vmcp.ConflictResolutionStrategy hasPrefixFormat bool hasPriorityOrder bool @@ -280,9 +281,9 @@ func TestConvertAggregation(t *testing.T) { }{ { name: "prefix strategy", - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + ConflictResolutionConfig: &vmcpcrd.ConflictResolutionConfig{ PrefixFormat: "{workload}_", }, }, @@ -291,9 +292,9 @@ func TestConvertAggregation(t *testing.T) { }, { name: "priority strategy", - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPriority, - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + ConflictResolutionConfig: &vmcpcrd.ConflictResolutionConfig{ PriorityOrder: []string{"backend-1", "backend-2"}, }, }, @@ -302,16 +303,16 @@ func TestConvertAggregation(t *testing.T) { }, { name: "with tool configs", - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: "backend-1", Filter: []string{"tool1", "tool2"}, }, { Workload: "backend-2", - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ "tool3": { Name: "renamed_tool3", Description: "Updated description", @@ -333,7 +334,7 @@ func TestConvertAggregation(t *testing.T) { vmcpServer := &mcpv1beta1.VirtualMCPServer{ Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Aggregation: tt.aggregation, }, }, @@ -369,17 +370,17 @@ func TestConvertCompositeTools(t *testing.T) { tests := []struct { name string - compositeTools []vmcpconfig.CompositeToolConfig + compositeTools []vmcpcrd.CompositeToolConfig expectedCount int }{ { name: "single composite tool", - compositeTools: []vmcpconfig.CompositeToolConfig{ + compositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "deploy_workflow", Description: "Deploy and verify", - Timeout: vmcpconfig.Duration(10 * time.Minute), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(10 * time.Minute), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "deploy", Type: mcpv1beta1.WorkflowStepTypeToolCall, @@ -392,11 +393,11 @@ func TestConvertCompositeTools(t *testing.T) { }, { name: "multiple composite tools", - compositeTools: []vmcpconfig.CompositeToolConfig{ + compositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "workflow1", Description: "Workflow 1", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: mcpv1beta1.WorkflowStepTypeToolCall, @@ -406,7 +407,7 @@ func TestConvertCompositeTools(t *testing.T) { { Name: "workflow2", Description: "Workflow 2", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: mcpv1beta1.WorkflowStepTypeElicitation, @@ -426,7 +427,7 @@ func TestConvertCompositeTools(t *testing.T) { vmcpServer := &mcpv1beta1.VirtualMCPServer{ Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ CompositeTools: tt.compositeTools, }, }, @@ -1079,14 +1080,14 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ // Aggregation with tool overrides (map) - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: "workload-1", - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ "tool-zebra": { Name: "renamed-zebra", Description: "Zebra tool", @@ -1104,13 +1105,13 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { }, }, // Operational with PerWorkload timeouts (map) - Operational: &vmcpconfig.OperationalConfig{ - Timeouts: &vmcpconfig.TimeoutConfig{ - Default: vmcpconfig.Duration(30 * time.Second), - PerWorkload: map[string]vmcpconfig.Duration{ - "workload-zebra": vmcpconfig.Duration(60 * time.Second), - "workload-alpha": vmcpconfig.Duration(45 * time.Second), - "workload-middle": vmcpconfig.Duration(50 * time.Second), + Operational: &vmcpcrd.OperationalConfig{ + Timeouts: &vmcpcrd.TimeoutConfig{ + Default: vmcpcrd.Duration(30 * time.Second), + PerWorkload: map[string]vmcpcrd.Duration{ + "workload-zebra": vmcpcrd.Duration(60 * time.Second), + "workload-alpha": vmcpcrd.Duration(45 * time.Second), + "workload-middle": vmcpcrd.Duration(50 * time.Second), }, }, }, @@ -1188,7 +1189,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "test-composite-tool", Description: "A test composite tool definition", Parameters: thvjson.NewMap(map[string]any{ @@ -1197,8 +1198,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { "message": map[string]any{"type": "string"}, }, }), - Timeout: vmcpconfig.Duration(30 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(30 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -1230,8 +1231,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "test-composite-tool"}, }, }, @@ -1310,10 +1311,10 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -1344,12 +1345,12 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeTools: []vmcpconfig.CompositeToolConfig{ + Config: vmcpcrd.Config{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "inline-tool", Description: "An inline composite tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -1358,7 +1359,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t }, }, }, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "referenced-tool"}, }, }, @@ -1441,8 +1442,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_NotFound(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "non-existent-tool"}, }, }, @@ -1888,8 +1889,8 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, - Config: vmcpconfig.Config{ - Optimizer: &vmcpconfig.OptimizerConfig{}, + Config: vmcpcrd.Config{ + Optimizer: &vmcpcrd.OptimizerConfig{}, }, EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", diff --git a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go index 175b7cd6f1..6a6236c632 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go @@ -26,7 +26,7 @@ import ( mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) // TestMapMCPGroupToVirtualMCPServer tests the MCPGroup watch handler @@ -1093,11 +1093,11 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "test-tool-config", }, }, @@ -1145,11 +1145,11 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "test-tool-config", }, }, @@ -1164,16 +1164,16 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "test-tool-config", }, }, { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "other-tool-config", }, }, @@ -1245,11 +1245,11 @@ func TestVmcpReferencesToolConfig(t *testing.T) { name: "VirtualMCPServer references ToolConfig", vmcp: &mcpv1beta1.VirtualMCPServer{ Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "test-config", }, }, @@ -1265,11 +1265,11 @@ func TestVmcpReferencesToolConfig(t *testing.T) { name: "VirtualMCPServer does not reference ToolConfig", vmcp: &mcpv1beta1.VirtualMCPServer{ Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "other-config", }, }, @@ -1293,21 +1293,21 @@ func TestVmcpReferencesToolConfig(t *testing.T) { name: "VirtualMCPServer references ToolConfig among multiple tools", vmcp: &mcpv1beta1.VirtualMCPServer{ Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "other-config", }, }, { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "test-config", }, }, { - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: "another-config", }, }, diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 7a89f271bd..98a7c297a6 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -6,6 +6,7 @@ package vmcpconfig import ( "context" + "encoding/json" "fmt" "github.com/go-logr/logr" @@ -18,6 +19,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/spectoconfig" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/vmcp/auth/converters" @@ -25,6 +27,28 @@ import ( vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) +// crdToRuntime transcodes an operator-owned vmcpcrd mirror value into its +// runtime config counterpart via JSON. The vmcpcrd.* types are a field-for-field +// duplicate of the pkg/vmcp/config types (identical JSON tags), so this is a +// total, lossless mapping for every passthrough field — the single boundary that +// keeps the CRD schema decoupled from the internal config model. Fields that +// require Kubernetes resolution (auth, tool config refs, telemetry refs, session +// storage) are overwritten by the explicit converters after this base mapping. +// +// Field-for-field parity is enforced by AssertNoDrift and the round-trip fuzz +// test; a transcode error here would indicate a genuine schema divergence. +func crdToRuntime[T any](src any) (*T, error) { + data, err := json.Marshal(src) + if err != nil { + return nil, fmt.Errorf("marshal CRD config: %w", err) + } + out := new(T) + if err := json.Unmarshal(data, out); err != nil { + return nil, fmt.Errorf("decode into runtime config: %w", err) + } + return out, nil +} + const ( // authzLabelValueInline is the string value for inline authz configuration authzLabelValueInline = "inline" @@ -69,10 +93,12 @@ func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Convert // Convert converts VirtualMCPServer CRD spec to a vmcp Config and an optional // auth server RunConfig. // -// The conversion starts with a DeepCopy of the embedded config.Config from the CRD spec. -// This ensures that simple fields (like Optimizer, Metadata, etc.) are automatically -// passed through without explicit mapping. Only fields that require special handling -// (auth, aggregation, composite tools, telemetry) are explicitly converted below. +// The conversion starts by transcoding the embedded vmcpcrd.Config (the CRD-owned +// mirror) into the runtime config.Config via crdToRuntime. This passes every +// simple field (Optimizer, Metadata, Backends, etc.) through losslessly without +// per-field mapping. Only fields that require Kubernetes resolution (auth, +// aggregation, composite tools, telemetry, session storage) are explicitly +// overwritten below. // // telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced). // It is passed in by the controller to avoid redundant API calls; normalizeTelemetry @@ -85,10 +111,13 @@ func (c *Converter) Convert( vmcp *mcpv1beta1.VirtualMCPServer, telemetryCfg *mcpv1beta1.MCPTelemetryConfig, ) (*vmcpconfig.Config, *authserver.RunConfig, error) { - // Start with a deep copy of the embedded config for automatic field passthrough. - // This ensures new fields added to config.Config are automatically included + // Transcode the CRD-owned mirror into the runtime config for automatic field + // passthrough. New passthrough fields added to both schemas flow through here // without requiring explicit mapping in this converter. - config := vmcp.Spec.Config.DeepCopy() + config, err := crdToRuntime[vmcpconfig.Config](&vmcp.Spec.Config) + if err != nil { + return nil, nil, fmt.Errorf("failed to transcode base config: %w", err) + } // Promoted top-level field takes precedence over spec.config.passthroughHeaders. if len(vmcp.Spec.PassthroughHeaders) > 0 { @@ -133,8 +162,8 @@ func (c *Converter) Convert( config.CompositeTools = compositeTools } - // Use Operational from spec.config directly - config.Operational = vmcp.Spec.Config.Operational + // Operational is passed through by crdToRuntime above (no Kubernetes + // resolution required); EnsureOperationalDefaults fills in any gaps below. // Normalize telemetry config: prefer TelemetryConfigRef (shared MCPTelemetryConfig resource), // The inline config.telemetry field is no longer read by the operator. @@ -740,7 +769,7 @@ func (c *Converter) convertAggregation( // applyConflictResolutionDefaults applies defaults for conflict resolution func (*Converter) applyConflictResolutionDefaults( - srcAgg *vmcpconfig.AggregationConfig, + srcAgg *vmcpcrd.AggregationConfig, agg *vmcpconfig.AggregationConfig, ) { // Apply default strategy if not set @@ -770,7 +799,7 @@ func (*Converter) applyConflictResolutionDefaults( func (c *Converter) resolveToolConfigRefs( ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, - srcAgg *vmcpconfig.AggregationConfig, + srcAgg *vmcpcrd.AggregationConfig, agg *vmcpconfig.AggregationConfig, ) error { if len(srcAgg.Tools) == 0 { @@ -788,12 +817,16 @@ func (c *Converter) resolveToolConfigRefs( ExcludeAll: toolConfig.ExcludeAll, } - // Copy inline overrides first + // Copy inline overrides first, transcoding each from the CRD mirror. if len(toolConfig.Overrides) > 0 { wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride) for name, override := range toolConfig.Overrides { if override != nil { - wtc.Overrides[name] = override.DeepCopy() + converted, err := crdToRuntime[vmcpconfig.ToolOverride](override) + if err != nil { + return fmt.Errorf("failed to convert tool override %q: %w", name, err) + } + wtc.Overrides[name] = converted } } } @@ -813,7 +846,7 @@ func (c *Converter) resolveToolConfigRef( ctx context.Context, ctxLogger logr.Logger, namespace string, - toolConfig *vmcpconfig.WorkloadToolConfig, + toolConfig *vmcpcrd.WorkloadToolConfig, wtc *vmcpconfig.WorkloadToolConfig, ) error { if toolConfig.ToolConfigRef == nil { @@ -915,8 +948,19 @@ func (c *Converter) convertAllCompositeTools( return nil, fmt.Errorf("failed to resolve composite tool references: %w", err) } + // Transcode inline composite tools from the CRD mirror into the runtime model. + inlineTools := make([]vmcpconfig.CompositeToolConfig, 0, len(vmcp.Spec.Config.CompositeTools)) + for i := range vmcp.Spec.Config.CompositeTools { + tool, err := crdToRuntime[vmcpconfig.CompositeToolConfig](&vmcp.Spec.Config.CompositeTools[i]) + if err != nil { + return nil, fmt.Errorf("failed to convert inline composite tool %q: %w", + vmcp.Spec.Config.CompositeTools[i].Name, err) + } + inlineTools = append(inlineTools, *tool) + } + // Merge inline and referenced tools - allTools := append(vmcp.Spec.Config.CompositeTools, referencedTools...) + allTools := append(inlineTools, referencedTools...) // Validate for duplicate names if err := validateCompositeToolNames(allTools); err != nil { @@ -951,7 +995,10 @@ func (c *Converter) resolveCompositeToolRefs( } // Convert the referenced definition to CompositeToolConfig - tool := c.convertCompositeToolDefinition(compositeToolDef) + tool, err := c.convertCompositeToolDefinition(compositeToolDef) + if err != nil { + return nil, err + } referencedTools = append(referencedTools, tool) } @@ -959,13 +1006,16 @@ func (c *Converter) resolveCompositeToolRefs( } // convertCompositeToolDefinition converts a VirtualMCPCompositeToolDefinition to CompositeToolConfig. -// Since VirtualMCPCompositeToolDefinitionSpec embeds config.CompositeToolConfig directly, -// this is a simple copy operation. +// VirtualMCPCompositeToolDefinitionSpec embeds the CRD-owned vmcpcrd.CompositeToolConfig mirror, +// so this transcodes it into the runtime config model via crdToRuntime. func (*Converter) convertCompositeToolDefinition( def *mcpv1beta1.VirtualMCPCompositeToolDefinition, -) vmcpconfig.CompositeToolConfig { - // The spec directly embeds CompositeToolConfig, so we can return it directly - return def.Spec.CompositeToolConfig +) (vmcpconfig.CompositeToolConfig, error) { + tool, err := crdToRuntime[vmcpconfig.CompositeToolConfig](&def.Spec.CompositeToolConfig) + if err != nil { + return vmcpconfig.CompositeToolConfig{}, fmt.Errorf("failed to convert composite tool %q: %w", def.Name, err) + } + return *tool, nil } // validateCompositeToolNames checks for duplicate tool names across all composite tools. diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index 0ebdd81d6b..3addb9600a 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -25,6 +25,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" oidcmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc/mocks" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" "github.com/stacklok/toolhive/pkg/telemetry" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -231,13 +232,13 @@ func TestConverter_CompositeToolsPassThrough(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeTools: []vmcpconfig.CompositeToolConfig{ + Config: vmcpcrd.Config{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "test-composite-tool", Description: "A test composite tool", - Timeout: vmcpconfig.Duration(30 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(30 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -473,8 +474,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "referenced-tool"}, }, }, @@ -487,10 +488,10 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -521,12 +522,12 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeTools: []vmcpconfig.CompositeToolConfig{ + Config: vmcpcrd.Config{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "inline-tool", Description: "An inline composite tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -535,7 +536,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "referenced-tool"}, }, }, @@ -548,10 +549,10 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -584,8 +585,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "non-existent-tool"}, }, }, @@ -604,12 +605,12 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeTools: []vmcpconfig.CompositeToolConfig{ + Config: vmcpcrd.Config{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: "duplicate-tool", Description: "An inline tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -618,7 +619,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "referenced-tool"}, }, }, @@ -631,10 +632,10 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "duplicate-tool", // Same name as inline tool Description: "A referenced tool with duplicate name", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -673,8 +674,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "tool1"}, {Name: "tool2"}, }, @@ -688,10 +689,10 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "tool1", Description: "First referenced tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -707,10 +708,10 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "tool2", Description: "Second referenced tool", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -742,8 +743,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "referenced-tool"}, }, }, @@ -756,7 +757,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced tool with parameters", Parameters: thvjson.NewMap(map[string]any{ @@ -765,8 +766,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { "param1": map[string]any{"type": "string"}, }, }), - Timeout: vmcpconfig.Duration(5 * time.Minute), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(5 * time.Minute), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Type: "tool", @@ -900,6 +901,14 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { }, } + // Create the equivalent CRD-mirror config for embedding in the CRD spec. + // The vmcpcrd mirror is field-for-field identical to vmcpconfig, so we + // transcode expectedConfig through JSON to populate the embed input. + var crdConfig vmcpcrd.CompositeToolConfig + expectedConfigJSON, err := json.Marshal(expectedConfig) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(expectedConfigJSON, &crdConfig)) + // Create a VirtualMCPCompositeToolDefinition with all fields populated compositeDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ @@ -907,7 +916,7 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { Namespace: "default", }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: expectedConfig, + CompositeToolConfig: crdConfig, }, } @@ -918,8 +927,8 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + Config: vmcpcrd.Config{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: "comprehensive-tool"}, }, }, @@ -1207,7 +1216,7 @@ func TestResolveToolConfigRefs(t *testing.T) { tests := []struct { name string - tools []*vmcpconfig.WorkloadToolConfig + tools []*vmcpcrd.WorkloadToolConfig existingConfig *mcpv1beta1.MCPToolConfig expectedWorkload string expectedFilter []string @@ -1215,10 +1224,10 @@ func TestResolveToolConfigRefs(t *testing.T) { }{ { name: "inline config only", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", Filter: []string{"tool1", "tool2"}, - Overrides: map[string]*vmcpconfig.ToolOverride{"tool1": vmcpToolOverride("renamed_tool1", "Renamed")}, + Overrides: map[string]*vmcpcrd.ToolOverride{"tool1": {Name: "renamed_tool1", Description: "Renamed"}}, }}, expectedWorkload: "backend1", expectedFilter: []string{"tool1", "tool2"}, @@ -1226,9 +1235,9 @@ func TestResolveToolConfigRefs(t *testing.T) { }, { name: "with MCPToolConfig reference", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "test-config"}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "test-config"}, }}, existingConfig: newMCPToolConfig("test-config", "default", []string{"fetch"}, map[string]mcpv1beta1.ToolOverride{"fetch": toolOverride("renamed_fetch", "Renamed fetch")}), @@ -1238,11 +1247,11 @@ func TestResolveToolConfigRefs(t *testing.T) { }, { name: "inline takes precedence", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", Filter: []string{"inline_tool"}, - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "test-config"}, - Overrides: map[string]*vmcpconfig.ToolOverride{"fetch": vmcpToolOverride("inline_fetch", "Inline override")}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "test-config"}, + Overrides: map[string]*vmcpcrd.ToolOverride{"fetch": {Name: "inline_fetch", Description: "Inline override"}}, }}, existingConfig: newMCPToolConfig("test-config", "default", []string{"config_tool"}, map[string]mcpv1beta1.ToolOverride{"fetch": toolOverride("config_fetch", "Config override")}), @@ -1267,7 +1276,7 @@ func TestResolveToolConfigRefs(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) converter.k8sClient = k8sClient - srcAgg := &vmcpconfig.AggregationConfig{Tools: tt.tools} + srcAgg := &vmcpcrd.AggregationConfig{Tools: tt.tools} vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, } @@ -1292,16 +1301,16 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { tests := []struct { name string - tools []*vmcpconfig.WorkloadToolConfig + tools []*vmcpcrd.WorkloadToolConfig existingConfig *mcpv1beta1.MCPToolConfig expectError bool expectedErrMsg string }{ { name: "error when MCPToolConfig reference not found (fail closed)", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "nonexistent-config"}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "nonexistent-config"}, }}, existingConfig: nil, // MCPToolConfig doesn't exist in cluster expectError: true, @@ -1309,7 +1318,7 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { }, { name: "no error when no ToolConfigRef specified", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", Filter: []string{"tool1"}, }}, @@ -1318,9 +1327,9 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { }, { name: "successful when MCPToolConfig exists", - tools: []*vmcpconfig.WorkloadToolConfig{{ + tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "valid-config"}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "valid-config"}, }}, existingConfig: newMCPToolConfig("valid-config", "default", []string{"fetch"}, nil), expectError: false, @@ -1342,7 +1351,7 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) converter.k8sClient = k8sClient - srcAgg := &vmcpconfig.AggregationConfig{Tools: tt.tools} + srcAgg := &vmcpcrd.AggregationConfig{Tools: tt.tools} vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, } @@ -1378,11 +1387,11 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "missing-config"}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "missing-config"}, }}, }, }, @@ -1398,11 +1407,11 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ - Aggregation: &vmcpconfig.AggregationConfig{ - Tools: []*vmcpconfig.WorkloadToolConfig{{ + Config: vmcpcrd.Config{ + Aggregation: &vmcpcrd.AggregationConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{{ Workload: "backend1", - ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "valid-config"}, + ToolConfigRef: &vmcpcrd.ToolConfigRef{Name: "valid-config"}, }}, }, }, @@ -1468,7 +1477,7 @@ func TestConverter_InlineTelemetryIgnored(t *testing.T) { IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Telemetry: &telemetry.Config{ Endpoint: "otlp-collector:4317", ServiceName: "should-be-ignored", @@ -1500,7 +1509,7 @@ func TestConverter_TelemetryNil(t *testing.T) { IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Telemetry: nil, // No telemetry config }, }, @@ -1521,7 +1530,7 @@ func TestConverter_SessionStorage(t *testing.T) { tests := []struct { name string sessionStorage *mcpv1beta1.SessionStorageConfig - inlineConfig *vmcpconfig.SessionStorageConfig + inlineConfig *vmcpcrd.SessionStorageConfig expectedStorage *vmcpconfig.SessionStorageConfig }{ { @@ -1554,7 +1563,7 @@ func TestConverter_SessionStorage(t *testing.T) { { name: "spec.config.sessionStorage is overwritten when spec.sessionStorage is nil", sessionStorage: nil, - inlineConfig: &vmcpconfig.SessionStorageConfig{ + inlineConfig: &vmcpcrd.SessionStorageConfig{ Provider: "redis", Address: "sneaky:6379", }, @@ -1573,7 +1582,7 @@ func TestConverter_SessionStorage(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ SessionStorage: tt.inlineConfig, }, SessionStorage: tt.sessionStorage, @@ -1602,7 +1611,7 @@ func TestConverter_RateLimitingPassThrough(t *testing.T) { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ RateLimiting: &mcpv1beta1.RateLimitConfig{ PerUser: &mcpv1beta1.RateLimitBucket{ MaxTokens: 2, @@ -2202,7 +2211,7 @@ func TestConverter_PassthroughHeaders(t *testing.T) { GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, PassthroughHeaders: topLevel, - Config: vmcpconfig.Config{PassthroughHeaders: configLevel}, + Config: vmcpcrd.Config{PassthroughHeaders: configLevel}, }, } } diff --git a/cmd/thv-operator/pkg/vmcpcrd/composite_validation.go b/cmd/thv-operator/pkg/vmcpcrd/composite_validation.go new file mode 100644 index 0000000000..f8ef86ab7e --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/composite_validation.go @@ -0,0 +1,741 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// This file is a deliberate duplicate of the composite-tool validation in +// pkg/vmcp/config/composite_validation.go, operating on the vmcpcrd mirror +// types. It exists so that the VirtualMCPServer / VirtualMCPCompositeToolDefinition +// admission webhooks can validate composite tools without api/v1beta1 importing +// pkg/vmcp/config (which would re-couple the CRD schema to the internal model). +// Keep it behaviourally in lock-step with the runtime copy. + +package vmcpcrd + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "text/template" + + "github.com/xeipuuv/gojsonschema" + + thvjson "github.com/stacklok/toolhive/pkg/json" + "github.com/stacklok/toolhive/pkg/templates" +) + +// Constants for workflow step types +const ( + WorkflowStepTypeToolCall = "tool" + WorkflowStepTypeElicitation = "elicitation" + WorkflowStepTypeForEach = "forEach" +) + +// Constants for error actions +const ( + ErrorActionAbort = "abort" + ErrorActionContinue = "continue" + ErrorActionRetry = "retry" +) + +// Constants for elicitation response actions +const ( + ElicitationResponseActionAbort = "abort" + ElicitationResponseActionContinue = "continue" + ElicitationResponseActionSkipRemaining = "skip_remaining" +) + +// ValidateCompositeToolConfig validates a CompositeToolConfig. +// This is the primary entry point for composite tool validation, used by both +// webhooks (VirtualMCPServer, VirtualMCPCompositeToolDefinition) and runtime validation. +func ValidateCompositeToolConfig(pathPrefix string, tool *CompositeToolConfig) error { + var errors []string + + // Validate required fields + if tool.Name == "" { + errors = append(errors, fmt.Sprintf("%s.name is required", pathPrefix)) + } + if tool.Description == "" { + errors = append(errors, fmt.Sprintf("%s.description is required", pathPrefix)) + } + if len(tool.Steps) == 0 { + errors = append(errors, fmt.Sprintf("%s.steps must have at least one step", pathPrefix)) + } + + // Timeout validation: Duration handles parsing, but check for negative + if tool.Timeout < 0 { + errors = append(errors, fmt.Sprintf("%s.timeout cannot be negative", pathPrefix)) + } + + // Validate parameters if present + if err := ValidateParameters(pathPrefix, tool.Parameters); err != nil { + errors = append(errors, err.Error()) + } + + // Validate steps + if len(tool.Steps) > 0 { + if err := ValidateWorkflowSteps(pathPrefix+".steps", tool.Steps); err != nil { + errors = append(errors, err.Error()) + } + + // Validate defaultResults for skippable steps + if err := ValidateDefaultResultsForSteps(pathPrefix+".steps", tool.Steps, tool.Output); err != nil { + errors = append(errors, err.Error()) + } + } + + if len(errors) > 0 { + return fmt.Errorf("validation failed: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// ValidateParameters validates the parameter schema (JSON Schema format). +func ValidateParameters(pathPrefix string, params thvjson.Map) error { + if params.IsEmpty() { + return nil + } + + paramsMap, err := params.ToMap() + if err != nil { + return fmt.Errorf("%s.parameters: invalid JSON: %w", pathPrefix, err) + } + + // Validate type field + typeVal, hasType := paramsMap["type"] + if !hasType { + return fmt.Errorf("%s.parameters: must have 'type' field (should be 'object' for JSON Schema)", pathPrefix) + } + + typeStr, ok := typeVal.(string) + if !ok { + return fmt.Errorf("%s.parameters: 'type' field must be a string", pathPrefix) + } + + if typeStr != "object" { + return fmt.Errorf("%s.parameters: 'type' must be 'object' (got '%s')", pathPrefix, typeStr) + } + + // Validate using JSON Schema validator + schemaBytes, err := params.MarshalJSON() + if err != nil { + return fmt.Errorf("%s.parameters: failed to marshal: %w", pathPrefix, err) + } + if err := ValidateJSONSchema(schemaBytes); err != nil { + return fmt.Errorf("%s.parameters: invalid JSON Schema: %w", pathPrefix, err) + } + + return nil +} + +// ValidateWorkflowSteps validates all workflow steps. +func ValidateWorkflowSteps(pathPrefix string, steps []WorkflowStepConfig) error { + stepIDs := make(map[string]bool) + stepIndices := make(map[string]int) + + // First pass: collect step IDs + for i, step := range steps { + if step.ID == "" { + return fmt.Errorf("%s[%d].id is required", pathPrefix, i) + } + if stepIDs[step.ID] { + return fmt.Errorf("%s[%d].id %q is duplicated", pathPrefix, i, step.ID) + } + stepIDs[step.ID] = true + stepIndices[step.ID] = i + } + + // Second pass: validate each step + for i := range steps { + if err := ValidateWorkflowStep(pathPrefix, i, &steps[i], stepIDs); err != nil { + return err + } + } + + // Third pass: validate no dependency cycles + return ValidateDependencyCycles(pathPrefix, steps) +} + +// ValidateWorkflowStep validates a single workflow step. +func ValidateWorkflowStep(pathPrefix string, index int, step *WorkflowStepConfig, stepIDs map[string]bool) error { + // Validate step type + if err := ValidateStepType(pathPrefix, index, step); err != nil { + return err + } + + // Validate templates + if err := ValidateStepTemplates(pathPrefix, index, step); err != nil { + return err + } + + // Validate dependencies + if err := ValidateStepDependencies(pathPrefix, index, step, stepIDs); err != nil { + return err + } + + // Validate error handling + if step.OnError != nil { + if err := ValidateStepErrorHandling(pathPrefix, index, step.OnError); err != nil { + return err + } + } + + // Validate elicitation response handlers + stepType := step.Type + if stepType == "" { + stepType = WorkflowStepTypeToolCall + } + if stepType == WorkflowStepTypeElicitation { + if step.OnDecline != nil { + if err := ValidateElicitationResponseHandler(pathPrefix, index, "onDecline", step.OnDecline); err != nil { + return err + } + } + if step.OnCancel != nil { + if err := ValidateElicitationResponseHandler(pathPrefix, index, "onCancel", step.OnCancel); err != nil { + return err + } + } + } + + return nil +} + +// ValidateStepType validates step type and type-specific required fields. +func ValidateStepType(pathPrefix string, index int, step *WorkflowStepConfig) error { + // Check for ambiguous configuration: both tool and message fields present without explicit type + if step.Type == "" && step.Tool != "" && step.Message != "" { + return fmt.Errorf( + "%s[%d] cannot have both tool and message fields - use explicit type to clarify intent", + pathPrefix, index) + } + + stepType := step.Type + if stepType == "" { + stepType = WorkflowStepTypeToolCall // default + } + + validTypes := map[string]bool{ + WorkflowStepTypeToolCall: true, + WorkflowStepTypeElicitation: true, + WorkflowStepTypeForEach: true, + } + if !validTypes[stepType] { + return fmt.Errorf("%s[%d].type must be one of: tool, elicitation, forEach", pathPrefix, index) + } + + if stepType == WorkflowStepTypeToolCall { + if step.Tool == "" { + return fmt.Errorf("%s[%d].tool is required when type is tool", pathPrefix, index) + } + if !IsValidToolReference(step.Tool) { + return fmt.Errorf("%s[%d].tool must be a valid tool name", pathPrefix, index) + } + } + + if stepType == WorkflowStepTypeElicitation && step.Message == "" { + return fmt.Errorf("%s[%d].message is required when type is elicitation", pathPrefix, index) + } + + if stepType == WorkflowStepTypeForEach { + if err := ValidateForEachStep(pathPrefix, index, step); err != nil { + return err + } + } + + return nil +} + +// MaxForEachIterations is the hard cap on forEach iterations. +const MaxForEachIterations = 1000 + +// ValidateForEachStep validates forEach-specific configuration. +func ValidateForEachStep(pathPrefix string, index int, step *WorkflowStepConfig) error { + // forEach must not have tool or message fields + if step.Tool != "" { + return fmt.Errorf("%s[%d]: forEach step must not have 'tool' field", pathPrefix, index) + } + if step.Message != "" { + return fmt.Errorf("%s[%d]: forEach step must not have 'message' field", pathPrefix, index) + } + + // collection is required and must be a valid template + if step.Collection == "" { + return fmt.Errorf("%s[%d].collection is required for forEach steps", pathPrefix, index) + } + if err := ValidateTemplate(step.Collection); err != nil { + return fmt.Errorf("%s[%d].collection: invalid template: %w", pathPrefix, index, err) + } + + // inner step is required + if step.InnerStep == nil { + return fmt.Errorf("%s[%d].step is required for forEach steps", pathPrefix, index) + } + + if err := validateForEachInnerStep(pathPrefix, index, step.InnerStep); err != nil { + return err + } + + return validateForEachLimits(pathPrefix, index, step) +} + +// validateForEachInnerStep validates the inner step of a forEach. +func validateForEachInnerStep(pathPrefix string, index int, inner *WorkflowStepConfig) error { + innerType := inner.Type + if innerType == "" { + innerType = WorkflowStepTypeToolCall + } + if innerType != WorkflowStepTypeToolCall { + return fmt.Errorf( + "%s[%d].step.type must be 'tool' (got %q); only tool inner steps are supported", + pathPrefix, index, innerType) + } + + if inner.Tool == "" { + return fmt.Errorf("%s[%d].step.tool is required for tool inner steps", pathPrefix, index) + } + if !IsValidToolReference(inner.Tool) { + return fmt.Errorf("%s[%d].step.tool must be a valid tool name", pathPrefix, index) + } + + if !inner.Arguments.IsEmpty() { + args, err := inner.Arguments.ToMap() + if err != nil { + return fmt.Errorf("%s[%d].step.arguments: invalid JSON: %w", pathPrefix, index, err) + } + for argName, argValue := range args { + if strValue, ok := argValue.(string); ok { + if err := ValidateTemplate(strValue); err != nil { + return fmt.Errorf("%s[%d].step.arguments[%s]: invalid template: %w", pathPrefix, index, argName, err) + } + } + } + } + + return nil +} + +// maxForEachParallel is the hard cap on forEach parallelism. +const maxForEachParallel = 50 + +// validateForEachLimits validates itemVar, maxParallel, and maxIterations. +func validateForEachLimits(pathPrefix string, index int, step *WorkflowStepConfig) error { + if step.ItemVar != "" { + if !isValidGoIdentifier(step.ItemVar) { + return fmt.Errorf("%s[%d].itemVar must be a valid Go identifier (got %q)", pathPrefix, index, step.ItemVar) + } + if step.ItemVar == "index" { + return fmt.Errorf("%s[%d].itemVar cannot be 'index' (reserved)", pathPrefix, index) + } + } + if step.MaxParallel < 0 { + return fmt.Errorf("%s[%d].maxParallel must be non-negative", pathPrefix, index) + } + if step.MaxParallel > maxForEachParallel { + return fmt.Errorf("%s[%d].maxParallel must be <= %d (got %d)", + pathPrefix, index, maxForEachParallel, step.MaxParallel) + } + if step.MaxIterations < 0 { + return fmt.Errorf("%s[%d].maxIterations must be non-negative", pathPrefix, index) + } + if step.MaxIterations > MaxForEachIterations { + return fmt.Errorf("%s[%d].maxIterations must be <= %d (got %d)", + pathPrefix, index, MaxForEachIterations, step.MaxIterations) + } + return nil +} + +// goIdentifierRegex matches valid Go identifiers. +var goIdentifierRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +// isValidGoIdentifier checks if s is a valid Go identifier. +func isValidGoIdentifier(s string) bool { + return s != "" && goIdentifierRegex.MatchString(s) +} + +// ValidateStepTemplates validates all template fields in a step. +func ValidateStepTemplates(pathPrefix string, index int, step *WorkflowStepConfig) error { + // Validate arguments + if !step.Arguments.IsEmpty() { + args, err := step.Arguments.ToMap() + if err != nil { + return fmt.Errorf("%s[%d].arguments: invalid JSON: %w", pathPrefix, index, err) + } + for argName, argValue := range args { + if strValue, ok := argValue.(string); ok { + if err := ValidateTemplate(strValue); err != nil { + return fmt.Errorf("%s[%d].arguments[%s]: invalid template: %w", pathPrefix, index, argName, err) + } + } + } + } + + // Validate condition + if step.Condition != "" { + if err := ValidateTemplate(step.Condition); err != nil { + return fmt.Errorf("%s[%d].condition: invalid template: %w", pathPrefix, index, err) + } + } + + // Validate message + if step.Message != "" { + if err := ValidateTemplate(step.Message); err != nil { + return fmt.Errorf("%s[%d].message: invalid template: %w", pathPrefix, index, err) + } + } + + // Validate JSON Schema for elicitation steps + if !step.Schema.IsEmpty() { + schemaBytes, err := step.Schema.MarshalJSON() + if err != nil { + return fmt.Errorf("%s[%d].schema: failed to marshal: %w", pathPrefix, index, err) + } + if err := ValidateJSONSchema(schemaBytes); err != nil { + return fmt.Errorf("%s[%d].schema: invalid JSON Schema: %w", pathPrefix, index, err) + } + } + + return nil +} + +// ValidateStepDependencies validates step dependencies reference existing steps. +func ValidateStepDependencies(pathPrefix string, index int, step *WorkflowStepConfig, stepIDs map[string]bool) error { + for _, depID := range step.DependsOn { + if !stepIDs[depID] { + return fmt.Errorf("%s[%d].dependsOn references unknown step %q", pathPrefix, index, depID) + } + } + return nil +} + +// ValidateStepErrorHandling validates error handling configuration. +func ValidateStepErrorHandling(pathPrefix string, index int, onError *StepErrorHandling) error { + if onError.Action == "" { + return nil // Action is optional, defaults to abort + } + + validActions := map[string]bool{ + ErrorActionAbort: true, + ErrorActionContinue: true, + ErrorActionRetry: true, + } + if !validActions[onError.Action] { + return fmt.Errorf("%s[%d].onError.action must be one of: abort, continue, retry", pathPrefix, index) + } + + if onError.Action == ErrorActionRetry && onError.RetryCount < 1 { + return fmt.Errorf("%s[%d].onError.retryCount must be at least 1 when action is retry", pathPrefix, index) + } + + return nil +} + +// ValidateElicitationResponseHandler validates elicitation response handlers. +func ValidateElicitationResponseHandler( + pathPrefix string, index int, handlerName string, handler *ElicitationResponseConfig, +) error { + if handler.Action == "" { + return fmt.Errorf("%s[%d].%s.action is required", pathPrefix, index, handlerName) + } + + validActions := map[string]bool{ + ElicitationResponseActionAbort: true, + ElicitationResponseActionContinue: true, + ElicitationResponseActionSkipRemaining: true, + } + if !validActions[handler.Action] { + return fmt.Errorf( + "%s[%d].%s.action must be one of: abort, continue, skip_remaining", + pathPrefix, index, handlerName) + } + + return nil +} + +// ValidateDependencyCycles validates that step dependencies don't create cycles. +func ValidateDependencyCycles(pathPrefix string, steps []WorkflowStepConfig) error { + // Build adjacency list + graph := make(map[string][]string) + for _, step := range steps { + graph[step.ID] = step.DependsOn + } + + // DFS cycle detection + visited := make(map[string]bool) + recStack := make(map[string]bool) + + var hasCycle func(string) bool + hasCycle = func(stepID string) bool { + visited[stepID] = true + recStack[stepID] = true + + for _, depID := range graph[stepID] { + if !visited[depID] { + if hasCycle(depID) { + return true + } + } else if recStack[depID] { + return true + } + } + + recStack[stepID] = false + return false + } + + for stepID := range graph { + if !visited[stepID] { + if hasCycle(stepID) { + return fmt.Errorf("%s: dependency cycle detected involving step %q", pathPrefix, stepID) + } + } + } + + return nil +} + +// stepFieldRef represents a reference to a specific field on a step's output. +type stepFieldRef struct { + stepID string + field string +} + +// ValidateDefaultResultsForSteps validates that defaultResults is specified for steps that: +// 1. May be skipped (have a condition or onError.action == "continue") +// 2. Are referenced by downstream steps +// +// nolint:gocyclo // multiple passes of the workflow are required to validate references are safe. +func ValidateDefaultResultsForSteps(pathPrefix string, steps []WorkflowStepConfig, output *OutputConfig) error { + // 1. Compute all skippable step IDs + skippableStepIDs := make(map[string]struct{}) + for _, step := range steps { + if stepMayBeSkipped(step) { + skippableStepIDs[step.ID] = struct{}{} + } + } + + if len(skippableStepIDs) == 0 { + return nil + } + + // 2. Compute map from skippable step ID to set of fields with default values + skippableStepDefaults := make(map[string]map[string]struct{}) + for _, step := range steps { + if _, ok := skippableStepIDs[step.ID]; ok { + skippableStepDefaults[step.ID] = make(map[string]struct{}) + if !step.DefaultResults.IsEmpty() { + defaultsMap, err := step.DefaultResults.ToMap() + if err == nil { + for key := range defaultsMap { + skippableStepDefaults[step.ID][key] = struct{}{} + } + } + } + } + } + + // 3. Check references in steps + for _, step := range steps { + refs, err := extractStepFieldRefsFromStep(step) + if err != nil { + return fmt.Errorf("failed to extract step references from step %s: %w", step.ID, err) + } + + for _, ref := range refs { + defaultFields, isSkippable := skippableStepDefaults[ref.stepID] + if !isSkippable { + continue + } + if _, hasDefault := defaultFields[ref.field]; !hasDefault { + return fmt.Errorf( + "%s[%s].defaultResults[%s] is required: step %q may be skipped and field %q is referenced by step %s", + pathPrefix, ref.stepID, ref.field, ref.stepID, ref.field, step.ID) + } + } + } + + // 4. Check references in output + if output != nil { + outputRefs, err := extractStepFieldRefsFromOutput(output) + if err != nil { + return fmt.Errorf("failed to extract step references from output: %w", err) + } + + for _, ref := range outputRefs { + defaultFields, isSkippable := skippableStepDefaults[ref.stepID] + if !isSkippable { + continue + } + if _, hasDefault := defaultFields[ref.field]; !hasDefault { + return fmt.Errorf( + "%s[%s].defaultResults[%s] is required: step %q may be skipped and field %q is referenced by output", + pathPrefix, ref.stepID, ref.field, ref.stepID, ref.field) + } + } + } + + return nil +} + +// stepMayBeSkipped returns true if a step may be skipped during execution. +func stepMayBeSkipped(step WorkflowStepConfig) bool { + if step.Condition != "" { + return true + } + if step.OnError != nil && step.OnError.Action == ErrorActionContinue { + return true + } + return false +} + +// extractStepFieldRefsFromStep extracts step field references from a step's templates. +func extractStepFieldRefsFromStep(step WorkflowStepConfig) ([]stepFieldRef, error) { + var allRefs []stepFieldRef + + if step.Condition != "" { + refs, err := extractStepFieldRefsFromTemplate(step.Condition) + if err != nil { + return nil, err + } + allRefs = append(allRefs, refs...) + } + + if !step.Arguments.IsEmpty() { + args, err := step.Arguments.ToMap() + if err == nil { + for _, argValue := range args { + if strValue, ok := argValue.(string); ok { + refs, err := extractStepFieldRefsFromTemplate(strValue) + if err != nil { + return nil, err + } + allRefs = append(allRefs, refs...) + } + } + } + } + + if step.Message != "" { + refs, err := extractStepFieldRefsFromTemplate(step.Message) + if err != nil { + return nil, err + } + allRefs = append(allRefs, refs...) + } + + return uniqueStepFieldRefs(allRefs), nil +} + +// extractStepFieldRefsFromOutput extracts step field references from output templates. +func extractStepFieldRefsFromOutput(output *OutputConfig) ([]stepFieldRef, error) { + if output == nil { + return nil, nil + } + + var allRefs []stepFieldRef + + for _, prop := range output.Properties { + if prop.Value != "" { + refs, err := extractStepFieldRefsFromTemplate(prop.Value) + if err != nil { + return nil, err + } + allRefs = append(allRefs, refs...) + } + + if len(prop.Properties) > 0 { + nestedOutput := &OutputConfig{Properties: prop.Properties} + nestedRefs, err := extractStepFieldRefsFromOutput(nestedOutput) + if err != nil { + return nil, err + } + allRefs = append(allRefs, nestedRefs...) + } + } + + return uniqueStepFieldRefs(allRefs), nil +} + +// extractStepFieldRefsFromTemplate extracts step output field references from a template string. +func extractStepFieldRefsFromTemplate(tmplStr string) ([]stepFieldRef, error) { + refs, err := templates.ExtractReferences(tmplStr) + if err != nil { + return nil, err + } + + var stepRefs []stepFieldRef + for _, ref := range refs { + if strings.HasPrefix(ref, ".steps.") { + parts := strings.SplitN(ref, ".", 6) + if len(parts) >= 5 && parts[3] == "output" { + stepRefs = append(stepRefs, stepFieldRef{ + stepID: parts[2], + field: parts[4], + }) + } + } + } + + return uniqueStepFieldRefs(stepRefs), nil +} + +// uniqueStepFieldRefs returns a deduplicated slice of stepFieldRefs. +func uniqueStepFieldRefs(refs []stepFieldRef) []stepFieldRef { + seen := make(map[stepFieldRef]struct{}) + result := make([]stepFieldRef, 0, len(refs)) + for _, r := range refs { + if _, ok := seen[r]; !ok { + seen[r] = struct{}{} + result = append(result, r) + } + } + return result +} + +// ValidateTemplate validates Go template syntax including custom functions. +// It uses the same FuncMap as the runtime template expander to ensure +// templates using json, quote, or fromJson are validated correctly. +func ValidateTemplate(tmpl string) error { + _, err := template.New("validation").Funcs(templates.FuncMap()).Parse(tmpl) + if err != nil { + return fmt.Errorf("invalid template syntax: %w", err) + } + return nil +} + +// ValidateJSONSchema validates that bytes contain a valid JSON Schema. +func ValidateJSONSchema(schemaBytes []byte) error { + if len(schemaBytes) == 0 { + return nil + } + + var schemaDoc interface{} + if err := json.Unmarshal(schemaBytes, &schemaDoc); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) + documentLoader := gojsonschema.NewStringLoader("{}") + + _, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return fmt.Errorf("invalid JSON Schema: %w", err) + } + + return nil +} + +// IsValidToolReference validates tool reference format. +// Accepts multiple formats: +// - "workload.tool_name" (semantic format specifying which backend's tool) +// - "workload_toolname" (aggregated format used with prefix conflict resolution) +// - "toolname" (simple format when there's no ambiguity) +func IsValidToolReference(tool string) bool { + if tool == "" { + return false + } + // Accept any reasonable tool name format: alphanumeric with dots, underscores, and hyphens + pattern := `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` + matched, _ := regexp.MatchString(pattern, tool) + return matched +} diff --git a/cmd/thv-operator/pkg/vmcpcrd/noleak_test.go b/cmd/thv-operator/pkg/vmcpcrd/noleak_test.go new file mode 100644 index 0000000000..ea2baec4dc --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/noleak_test.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmcpcrd_test + +import ( + "reflect" + "strings" + "testing" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// internalConfigPkgPath is the import path of the internal runtime config model. +// No type reachable from the CRD spec types may originate here — if one does, +// controller-gen could walk the internal package into the generated CRD schema, +// re-coupling the two. This is the categorical no-leak guarantee. +const internalConfigPkgPath = "github.com/stacklok/toolhive/pkg/vmcp/config" + +// TestNoInternalConfigLeak walks every type reachable from the CRD spec types +// and fails if any reachable type belongs to pkg/vmcp/config. Unlike the +// JSON-leaf parity test, this walk preserves type identity (PkgPath), which is +// exactly what controller-gen sees when it builds the schema. +func TestNoInternalConfigLeak(t *testing.T) { + t.Parallel() + + roots := []reflect.Type{ + reflect.TypeOf(mcpv1beta1.VirtualMCPServerSpec{}), + reflect.TypeOf(mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{}), + } + + for _, root := range roots { + root := root + t.Run(root.Name(), func(t *testing.T) { + t.Parallel() + w := &leakWalker{visited: map[reflect.Type]struct{}{}, t: t} + w.walk(root, root.Name()) + }) + } +} + +// leakWalker performs a depth-first walk over a type graph, dereferencing +// pointers/slices/arrays/maps and recursing into struct fields. It skips +// metav1 and other apimachinery types (which legitimately live outside our +// control) and breaks cycles via the visited set. +type leakWalker struct { + visited map[reflect.Type]struct{} + t *testing.T +} + +func (w *leakWalker) walk(t reflect.Type, path string) { + if t == nil { + return + } + + // Unwrap pointer/container kinds to reach the underlying named type. Each + // unwrap re-enters walk so the element type is checked for the leak too. + switch t.Kind() { //nolint:exhaustive // only container kinds need unwrapping; all others fall through to the named-type check below + + case reflect.Pointer, reflect.Slice, reflect.Array: + w.walk(t.Elem(), path+"[]") + return + case reflect.Map: + w.walk(t.Key(), path+"{key}") + w.walk(t.Elem(), path+"{val}") + return + default: + } + + // Named types: check the originating package first. This is the actual + // assertion — a field whose type lives in pkg/vmcp/config is a leak. + if t.PkgPath() == internalConfigPkgPath { + w.t.Errorf( + "no-leak violation: %s has type %s.%s from the internal config package %q.\n"+ + "CRD spec types must reference only the operator-owned vmcpcrd mirror so controller-gen "+ + "cannot walk pkg/vmcp/config into the generated schema.", + path, t.PkgPath(), t.Name(), internalConfigPkgPath, + ) + } + + // Skip apimachinery / k8s.io types: they are not ours to police and pull in + // large, cyclic graphs (e.g. runtime.RawExtension). + if isSkippedPkg(t.PkgPath()) { + return + } + + if t.Kind() != reflect.Struct { + return + } + + // Cycle break: only struct types are tracked since only they recurse into + // fields. Mark before descending, leave marked (a re-visit anywhere in the + // graph is safe to prune — the type was already fully checked). + if _, seen := w.visited[t]; seen { + return + } + w.visited[t] = struct{}{} + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + w.walk(field.Type, path+"."+field.Name) + } +} + +// isSkippedPkg reports whether a package path belongs to Kubernetes +// apimachinery / k8s.io machinery that we deliberately do not recurse into. +// pkg/vmcp/config is intentionally NOT skipped — it is the package we are +// asserting against. +func isSkippedPkg(pkgPath string) bool { + if pkgPath == "" { + return false // builtins / unnamed types: keep walking their structure + } + return strings.HasPrefix(pkgPath, "k8s.io/") || + strings.HasPrefix(pkgPath, "sigs.k8s.io/") +} diff --git a/cmd/thv-operator/pkg/vmcpcrd/parity_test.go b/cmd/thv-operator/pkg/vmcpcrd/parity_test.go new file mode 100644 index 0000000000..85bb81c4a2 --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/parity_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package vmcpcrd_test holds drift-detection tests that must import both the +// operator-owned CRD mirror (cmd/thv-operator/pkg/vmcpcrd) and the internal +// runtime config model (pkg/vmcp/config). It lives in an external test package +// so the non-test vmcpcrd package never takes a build-time dependency on +// pkg/vmcp/config — the whole point of the decoupling. +package vmcpcrd_test + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" + "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +// TestConfigSchemaParity asserts that the operator-owned vmcpcrd.Config mirror +// has the exact same set of JSON leaf paths as the runtime config.Config. This +// is the field-for-field parity guarantee: when one side gains, renames, or +// drops a field, the symmetric difference below names the drifted leaf so the +// developer immediately knows which side to fix. +func TestConfigSchemaParity(t *testing.T) { + t.Parallel() + + runtimeLeaves := testutil.FlattenJSONLeafFields(reflect.TypeOf(config.Config{})) + mirrorLeaves := testutil.FlattenJSONLeafFields(reflect.TypeOf(vmcpcrd.Config{})) + + if reflect.DeepEqual(runtimeLeaves, mirrorLeaves) { + return + } + + onlyInRuntime := setDifference(runtimeLeaves, mirrorLeaves) + onlyInMirror := setDifference(mirrorLeaves, runtimeLeaves) + + t.Errorf( + "vmcpcrd.Config and config.Config JSON schemas have drifted.\n"+ + "Leaves only in config.Config (pkg/vmcp/config): %s\n"+ + "Leaves only in vmcpcrd.Config (operator mirror): %s\n"+ + "Action: re-sync the two struct definitions field-for-field (and the converter) until they match.", + formatLeaves(onlyInRuntime), formatLeaves(onlyInMirror), + ) +} + +// setDifference returns the elements present in a but not in b, sorted. +func setDifference(a, b []string) []string { + bSet := make(map[string]struct{}, len(b)) + for _, s := range b { + bSet[s] = struct{}{} + } + var out []string + for _, s := range a { + if _, ok := bSet[s]; !ok { + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +// formatLeaves renders a leaf list for a failure message, using "(none)" when +// empty so the message is unambiguous. +func formatLeaves(leaves []string) string { + if len(leaves) == 0 { + return "(none)" + } + return "[" + strings.Join(leaves, ", ") + "]" +} diff --git a/cmd/thv-operator/pkg/vmcpcrd/roundtrip_test.go b/cmd/thv-operator/pkg/vmcpcrd/roundtrip_test.go new file mode 100644 index 0000000000..0b0f801592 --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/roundtrip_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmcpcrd_test + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/randfill" + + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" + thvjson "github.com/stacklok/toolhive/pkg/json" + "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +// TestRoundTripTranscode fuzzes a vmcpcrd.Config, transcodes it to +// config.Config exactly the way the converter's crdToRuntime does (json.Marshal +// then json.Unmarshal), transcodes it back, and asserts the value survives the +// double crossing. This catches value loss in the JSON boundary that schema +// parity alone cannot: a field present on both sides but serialized under +// different semantics would silently drop here. +// +// Seeds are the loop index, not wall-clock time — the run is fully +// deterministic and reproducible. +func TestRoundTripTranscode(t *testing.T) { + t.Parallel() + + const iterations = 100 + for i := 0; i < iterations; i++ { + i := i + t.Run(fmt.Sprintf("seed-%d", i), func(t *testing.T) { + t.Parallel() + + original := fuzzMirrorConfig(int64(i)) + + // Forward transcode: vmcpcrd.Config -> config.Config, identical to + // crdToRuntime in cmd/thv-operator/pkg/vmcpconfig/converter.go. + runtime := transcode[config.Config](t, original) + + // Reverse transcode back into the mirror type. + roundTripped := transcode[vmcpcrd.Config](t, runtime) + + // thvjson.Map / thvjson.Any decode JSON numbers as float64 and store + // arbitrary interface{} content, so reflect.DeepEqual on the structs + // can report spurious mismatches even when the JSON is identical + // (e.g. an int that became a float64). We populate those fields with + // only string-valued, JSON-stable content in the fuzzer, so + // DeepEqual is reliable here; but to be robust against any residual + // interface{} ambiguity we compare canonical JSON as the source of + // truth and use DeepEqual as a secondary, stricter check. + origJSON := mustMarshal(t, original) + rtJSON := mustMarshal(t, roundTripped) + require.JSONEq(t, string(origJSON), string(rtJSON), + "canonical JSON diverged after round-trip transcode (seed %d)", i) + + require.True(t, reflect.DeepEqual(original, roundTripped), + "vmcpcrd.Config did not survive round-trip transcode (seed %d)\noriginal: %s\nroundtripped: %s", + i, origJSON, rtJSON) + }) + } +} + +// transcode mirrors crdToRuntime: marshal the source to JSON, then unmarshal +// into T. Any error is a genuine schema/converter defect, so the test fails. +func transcode[T any](t *testing.T, src any) T { + t.Helper() + data, err := json.Marshal(src) + require.NoError(t, err, "marshal source for transcode") + var out T + require.NoError(t, json.Unmarshal(data, &out), "unmarshal into %T", out) + return out +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return data +} + +// fuzzMirrorConfig builds a deterministically-seeded, fully-populated +// vmcpcrd.Config. Custom fill funcs handle the three custom-marshaler field +// families that a naive fuzz would corrupt: +// - vmcpcrd.Duration marshals as a Go duration string, so it must be filled +// with a whole-unit duration that round-trips cleanly ("42s", not "42ns" +// which is fine too, but fractional/odd values stay exact in seconds). +// - thvjson.Map / thvjson.Any wrap interface{} content; we fill them with a +// small map of string->string so the value survives JSON (no int/float64 +// ambiguity, no NaN, no key reordering issues). +func fuzzMirrorConfig(seed int64) vmcpcrd.Config { + f := randfill.NewWithSeed(seed). + // Always populate optional pointers/maps/slices so the round-trip + // actually exercises every field rather than leaving them nil. + NilChance(0). + NumElements(1, 3). + MaxDepth(8). + Funcs( + // Duration: whole seconds keep the string form lossless ("Ns"). + func(d *vmcpcrd.Duration, c randfill.Continue) { + secs := c.Int63n(3600) // 0..1h, whole seconds + *d = vmcpcrd.Duration(time.Duration(secs) * time.Second) + }, + // thvjson.Map: JSON object with only string values. + func(m *thvjson.Map, c randfill.Continue) { + *m = thvjson.NewMap(stableStringMap(c)) + }, + // thvjson.Any: store a JSON-stable string map as well. + func(a *thvjson.Any, c randfill.Continue) { + *a = thvjson.NewAny(stableStringMap(c)) + }, + ) + + var cfg vmcpcrd.Config + f.Fill(&cfg) + return cfg +} + +// stableStringMap produces a small map[string]any whose values are all strings, +// guaranteeing it survives a JSON round-trip with reflect.DeepEqual intact. +func stableStringMap(c randfill.Continue) map[string]any { + n := 1 + c.Intn(3) + m := make(map[string]any, n) + for i := 0; i < n; i++ { + m[fmt.Sprintf("k%d_%s", i, c.String(4))] = c.String(6) + } + return m +} diff --git a/cmd/thv-operator/pkg/vmcpcrd/vmcpconfig_types.go b/cmd/thv-operator/pkg/vmcpcrd/vmcpconfig_types.go new file mode 100644 index 0000000000..a2cbf3e476 --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/vmcpconfig_types.go @@ -0,0 +1,952 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package vmcpcrd holds the CRD-facing mirror of the Virtual MCP Server +// configuration schema. +// +// These types are a deliberate, standalone duplicate of the runtime +// configuration model in pkg/vmcp/config. They exist so that the +// VirtualMCPServer / VirtualMCPCompositeToolDefinition CRD schemas are +// generated exclusively from types owned by the operator API package, and can +// never be perturbed by a change to the internal on-disk/runtime config model. +// +// The decoupling is structural: no field on any type in this package may +// reference a type defined in pkg/vmcp/config. That invariant is what makes the +// no-leak guarantee categorical (controller-gen physically cannot walk into the +// internal package), and it is enforced by a reflection-based test. The +// cmd/thv-operator/pkg/vmcpconfig converter is the single boundary that turns +// these CRD types into the internal config.Config that is marshalled to the +// ConfigMap. +// +// Field-for-field parity with pkg/vmcp/config is enforced by the AssertNoDrift +// tables and a round-trip fuzz test; when one side gains or changes a field, +// those tests fail until the other side and the converter are updated in step. +package vmcpcrd + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/stacklok/toolhive/pkg/audit" + thvjson "github.com/stacklok/toolhive/pkg/json" + ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" + "github.com/stacklok/toolhive/pkg/telemetry" + "github.com/stacklok/toolhive/pkg/vmcp" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// Duration is a wrapper around time.Duration that marshals/unmarshals as a duration string. +// This ensures duration values are serialized as "30s", "1m", etc. instead of nanosecond integers. +// +kubebuilder:validation:Type=string +// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` +type Duration time.Duration + +// MarshalJSON implements json.Marshaler. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *Duration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration: %w", err) + } + *d = Duration(dur) + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (d Duration) MarshalYAML() (interface{}, error) { + return time.Duration(d).String(), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration: %w", err) + } + *d = Duration(dur) + return nil +} + +// Config is the unified configuration model for Virtual MCP Server. +// This is platform-agnostic and used by both CLI and Kubernetes deployments. +// +// Platform-specific adapters (CLI YAML loader, Kubernetes CRD converter) +// transform their native formats into this model. +// +kubebuilder:object:generate=true +// +kubebuilder:pruning:PreserveUnknownFields +// +kubebuilder:validation:Type=object +// +gendoc +type Config struct { + // Name is the virtual MCP server name. + // +optional + Name string `json:"name,omitempty" yaml:"name,omitempty"` + + // Group references an existing MCPGroup that defines backend workloads. + // In standalone CLI mode, this is set from the YAML config file. + // In Kubernetes, the operator populates this from spec.groupRef during conversion. + // +optional + Group string `json:"groupRef,omitempty" yaml:"groupRef,omitempty"` + + // Backends defines pre-configured backend servers for static mode. + // When OutgoingAuth.Source is "inline", this field contains the full list of backend + // servers with their URLs and transport types, eliminating the need for K8s API access. + // When OutgoingAuth.Source is "discovered", this field is empty and backends are + // discovered at runtime via Kubernetes API. + // +optional + Backends []StaticBackendConfig `json:"backends,omitempty" yaml:"backends,omitempty"` + + // IncomingAuth configures how clients authenticate to the virtual MCP server. + // When using the Kubernetes operator, this is populated by the converter from + // VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. + // +optional + IncomingAuth *IncomingAuthConfig `json:"incomingAuth,omitempty" yaml:"incomingAuth,omitempty"` + + // OutgoingAuth configures how the virtual MCP server authenticates to backends. + // When using the Kubernetes operator, this is populated by the converter from + // VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. + // +optional + OutgoingAuth *OutgoingAuthConfig `json:"outgoingAuth,omitempty" yaml:"outgoingAuth,omitempty"` + + // Aggregation defines tool aggregation and conflict resolution strategies. + // Supports ToolConfigRef for Kubernetes-native MCPToolConfig resource references. + // +optional + Aggregation *AggregationConfig `json:"aggregation,omitempty" yaml:"aggregation,omitempty"` + + // CompositeTools defines inline composite tool workflows. + // Full workflow definitions are embedded in the configuration. + // For Kubernetes, complex workflows can also reference VirtualMCPCompositeToolDefinition CRDs. + // +optional + CompositeTools []CompositeToolConfig `json:"compositeTools,omitempty" yaml:"compositeTools,omitempty"` + + // CompositeToolRefs references VirtualMCPCompositeToolDefinition resources + // for complex, reusable workflows. Only applicable when running in Kubernetes. + // Referenced resources must be in the same namespace as the VirtualMCPServer. + // +optional + CompositeToolRefs []CompositeToolRef `json:"compositeToolRefs,omitempty" yaml:"compositeToolRefs,omitempty"` + + // Operational configures operational settings. + Operational *OperationalConfig `json:"operational,omitempty" yaml:"operational,omitempty"` + + // Metadata stores additional configuration metadata. + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` + + // Telemetry configures OpenTelemetry-based observability for the Virtual MCP server + // including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + // Deprecated (Kubernetes operator only): When deploying via the operator, use + // VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + // resource instead. This field remains valid for standalone (non-operator) deployments. + // +optional + Telemetry *telemetry.Config `json:"telemetry,omitempty" yaml:"telemetry,omitempty"` + + // Audit configures audit logging for the Virtual MCP server. + // When present, audit logs include MCP protocol operations. + // See audit.Config for available configuration options. + // +optional + Audit *audit.Config `json:"audit,omitempty" yaml:"audit,omitempty"` + + // Optimizer configures the MCP optimizer for context optimization on large toolsets. + // When enabled, vMCP exposes only find_tool and call_tool operations to clients + // instead of all backend tools directly. This reduces token usage by allowing + // LLMs to discover relevant tools on demand rather than receiving all tool definitions. + // +optional + Optimizer *OptimizerConfig `json:"optimizer,omitempty" yaml:"optimizer,omitempty"` + + // SessionStorage configures session storage for stateful horizontal scaling. + // When provider is "redis", the operator injects Redis connection parameters + // (address, db, keyPrefix) here. The Redis password is provided separately via + // the THV_SESSION_REDIS_PASSWORD environment variable. + // +optional + SessionStorage *SessionStorageConfig `json:"sessionStorage,omitempty" yaml:"sessionStorage,omitempty"` + + // RateLimiting defines rate limiting configuration for the Virtual MCP server. + // Requires Redis session storage to be configured for distributed rate limiting. + // +optional + RateLimiting *ratelimittypes.RateLimitConfig `json:"rateLimiting,omitempty" yaml:"rateLimiting,omitempty"` + + // PassthroughHeaders is an allowlist of incoming client request header names + // forwarded verbatim to all backends. Captured at the vMCP incoming edge by + // headerforward.CaptureMiddleware and consumed once at session creation + // when the per-session backend client's HeaderForwardConfig is built. Names + // must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.). + // +optional + // +listType=atomic + PassthroughHeaders []string `json:"passthroughHeaders,omitempty" yaml:"passthroughHeaders,omitempty"` +} + +// IncomingAuthConfig configures client authentication to the virtual MCP server. +// +// Note: When using the Kubernetes operator (VirtualMCPServer CRD), the +// VirtualMCPServerSpec.IncomingAuth field is the authoritative source for +// authentication configuration. The operator's converter will resolve the CRD's +// IncomingAuth (which supports Kubernetes-native references like SecretKeyRef, +// ConfigMapRef, etc.) and populate this IncomingAuthConfig with the resolved values. +// Any values set here directly will be superseded by the CRD configuration. +// +// +kubebuilder:object:generate=true +// +gendoc +type IncomingAuthConfig struct { + // Type is the auth type: "oidc", "local", "anonymous" + Type string `json:"type" yaml:"type"` + + // OIDC contains OIDC configuration (when Type = "oidc"). + OIDC *OIDCConfig `json:"oidc,omitempty" yaml:"oidc,omitempty"` + + // Authz contains authorization configuration (optional). + Authz *AuthzConfig `json:"authz,omitempty" yaml:"authz,omitempty"` +} + +// OIDCConfig configures OpenID Connect authentication. +// +kubebuilder:object:generate=true +// +gendoc +type OIDCConfig struct { + // Issuer is the OIDC issuer URL. + // +kubebuilder:validation:Pattern=`^https?://` + Issuer string `json:"issuer" yaml:"issuer"` + + // ClientID is the OAuth client ID. + ClientID string `json:"clientId" yaml:"clientId"` + + // ClientSecretEnv is the name of the environment variable containing the client secret. + // This is the secure way to reference secrets - the actual secret value is never stored + // in configuration files, only the environment variable name. + // The secret value will be resolved from this environment variable at runtime. + ClientSecretEnv string `json:"clientSecretEnv,omitempty" yaml:"clientSecretEnv,omitempty"` + + // Audience is the required token audience. + Audience string `json:"audience" yaml:"audience"` + + // Resource is the OAuth 2.0 resource indicator (RFC 8707). + // Used in WWW-Authenticate header and OAuth discovery metadata (RFC 9728). + // If not specified, defaults to Audience. + Resource string `json:"resource,omitempty" yaml:"resource,omitempty"` + + // JWKSURL is the explicit JWKS endpoint URL. + // When set, skips OIDC discovery and fetches the JWKS directly from this URL. + // This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration. + // +optional + JWKSURL string `json:"jwksUrl,omitempty" yaml:"jwksUrl,omitempty"` + + // IntrospectionURL is the token introspection endpoint URL (RFC 7662). + // When set, enables token introspection for opaque (non-JWT) tokens. + // +optional + IntrospectionURL string `json:"introspectionUrl,omitempty" yaml:"introspectionUrl,omitempty"` + + // Scopes are the required OAuth scopes. + Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` + + // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses + // Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP bool `json:"protectedResourceAllowPrivateIp,omitempty" yaml:"protectedResourceAllowPrivateIp,omitempty"` //nolint:lll + + // JwksAllowPrivateIP allows OIDC discovery and JWKS fetches to private IP addresses. + // Enable when the embedded auth server runs on a loopback address and + // the OIDC middleware needs to fetch its JWKS from that address. + // Use with caution - only enable for trusted internal IDPs or testing. + JwksAllowPrivateIP bool `json:"jwksAllowPrivateIp,omitempty" yaml:"jwksAllowPrivateIp,omitempty"` + + // InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing + // WARNING: This is insecure and should NEVER be used in production + InsecureAllowHTTP bool `json:"insecureAllowHttp,omitempty" yaml:"insecureAllowHttp,omitempty"` +} + +// AuthzConfig configures authorization. +// +kubebuilder:object:generate=true +// +gendoc +type AuthzConfig struct { + // Type is the authz type: "cedar", "none" + Type string `json:"type" yaml:"type"` + + // Policies contains Cedar policy definitions (when Type = "cedar"). + Policies []string `json:"policies,omitempty" yaml:"policies,omitempty"` + + // EntitiesJSON is a JSON string representing Cedar entities. Required for + // enterprise policies that rely on transitive relationships (e.g. + // `ClaimGroup → PlatformRole`) — without it the Cedar authorizer is + // constructed with an empty entity store and `in` checks against absent + // entities silently evaluate to false. Defaults to "[]" when empty. + // +optional + EntitiesJSON string `json:"entitiesJson,omitempty" yaml:"entitiesJson,omitempty"` + + // PrimaryUpstreamProvider names the upstream IDP provider whose access + // token should be used as the source of JWT claims for Cedar evaluation. + // When empty, claims from the ToolHive-issued token are used. + // Must match an upstream provider name configured in the embedded auth server + // (e.g. "default", "github"). Only relevant when the embedded auth server is active. + // +optional + PrimaryUpstreamProvider string `json:"primaryUpstreamProvider,omitempty" yaml:"primaryUpstreamProvider,omitempty"` + + // GroupClaimName is the JWT claim key that contains group membership for + // the principal. When set, takes priority over the well-known defaults + // ("groups", "roles", "cognito:groups"). Use this for IDPs that place + // groups under a URI-style claim (e.g. "https://example.com/groups"). + // When empty, only the well-known claim names are checked. + // +optional + GroupClaimName string `json:"groupClaimName,omitempty" yaml:"groupClaimName,omitempty"` + + // RoleClaimName is the JWT claim key that contains role membership for the + // principal. When set, the claim is extracted separately from GroupClaimName + // and both are mapped to the configured group entity type. When empty, no + // role extraction is performed. + // +optional + RoleClaimName string `json:"roleClaimName,omitempty" yaml:"roleClaimName,omitempty"` + + // GroupEntityType is the Cedar entity type name used for principal parent + // UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when + // empty. Must match the entity type used in EntitiesJSON for transitive + // `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported. + // +optional + GroupEntityType string `json:"groupEntityType,omitempty" yaml:"groupEntityType,omitempty"` +} + +// StaticBackendConfig defines a pre-configured backend server for static mode. +// This allows vMCP to operate without Kubernetes API access by embedding all backend +// information directly in the configuration. +// +gendoc +// +kubebuilder:object:generate=true +type StaticBackendConfig struct { + // Name is the backend identifier. + // Must match the backend name from the MCPGroup for auth config resolution. + // +kubebuilder:validation:Required + Name string `json:"name" yaml:"name"` + + // URL is the backend's MCP server base URL. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^https?://` + URL string `json:"url" yaml:"url"` + + // Transport is the MCP transport protocol: "sse" or "streamable-http" + // Only network transports supported by vMCP client are allowed. + // +kubebuilder:validation:Enum=sse;streamable-http + // +kubebuilder:validation:Required + Transport string `json:"transport" yaml:"transport"` + + // Type is the backend workload type: "entry" for MCPServerEntry backends, or empty + // for container/proxy backends. Entry backends connect directly to remote MCP servers. + // +kubebuilder:validation:Enum=entry;"" + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // CABundlePath is the file path to a custom CA certificate bundle for TLS verification. + // Only valid when Type is "entry". The operator mounts CA bundles at + // /etc/toolhive/ca-bundles//ca.crt. + // +optional + CABundlePath string `json:"caBundlePath,omitempty" yaml:"caBundlePath,omitempty"` + + // Metadata is a custom key-value map for storing additional backend information + // such as labels, tags, or other arbitrary data (e.g., "env": "prod", "region": "us-east-1"). + // This is NOT Kubernetes ObjectMeta - it's a simple string map for user-defined metadata. + // Reserved keys: "group" is automatically set by vMCP and any user-provided value will be overridden. + // +optional + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// OutgoingAuthConfig configures backend authentication. +// +// Note: When using the Kubernetes operator (VirtualMCPServer CRD), the +// VirtualMCPServerSpec.OutgoingAuth field is the authoritative source for +// backend authentication configuration. The operator's converter will resolve +// the CRD's OutgoingAuth (which supports Kubernetes-native references like +// SecretKeyRef, ConfigMapRef, etc.) and populate this OutgoingAuthConfig with +// the resolved values. Any values set here directly will be superseded by the +// CRD configuration. +// +// +kubebuilder:object:generate=true +// +gendoc +type OutgoingAuthConfig struct { + // Source defines how to discover backend auth: "inline", "discovered" + // - inline: Explicit configuration in OutgoingAuth + // - discovered: Auto-discover from backend MCPServer.externalAuthConfigRef (Kubernetes only) + Source string `json:"source" yaml:"source"` + + // Default is the default auth strategy for backends without explicit config. + Default *authtypes.BackendAuthStrategy `json:"default,omitempty" yaml:"default,omitempty"` + + // Backends contains per-backend auth configuration. + Backends map[string]*authtypes.BackendAuthStrategy `json:"backends,omitempty" yaml:"backends,omitempty"` +} + +// AggregationConfig defines tool aggregation, filtering, and conflict resolution strategies. +// +// Tool Visibility vs Routing: +// - ExcludeAllTools, per-workload ExcludeAll, and Filter control which tools are +// advertised to MCP clients (visible in tools/list responses). +// - ALL backend tools remain available in the internal routing table, allowing +// composite tools to call hidden backend tools. +// - This enables curated experiences where raw backend tools are hidden from +// MCP clients but accessible through composite tool workflows. +// +// +kubebuilder:object:generate=true +// +gendoc +type AggregationConfig struct { + // ConflictResolution defines the strategy for resolving tool name conflicts. + // - prefix: Automatically prefix tool names with workload identifier + // - priority: First workload in priority order wins + // - manual: Explicitly define overrides for all conflicts + // +kubebuilder:validation:Enum=prefix;priority;manual + // +kubebuilder:default=prefix + // +optional + ConflictResolution vmcp.ConflictResolutionStrategy `json:"conflictResolution" yaml:"conflictResolution"` + + // ConflictResolutionConfig provides configuration for the chosen strategy. + // +optional + ConflictResolutionConfig *ConflictResolutionConfig `json:"conflictResolutionConfig,omitempty" yaml:"conflictResolutionConfig,omitempty"` //nolint:lll + + // Tools defines per-workload tool filtering and overrides. + // +optional + Tools []*WorkloadToolConfig `json:"tools,omitempty" yaml:"tools,omitempty"` + + // ExcludeAllTools hides all backend tools from MCP clients when true. + // Hidden tools are NOT advertised in tools/list responses, but they ARE + // available in the routing table for composite tools to use. + // This enables the use case where you want to hide raw backend tools from + // direct client access while exposing curated composite tool workflows. + // +optional + ExcludeAllTools bool `json:"excludeAllTools,omitempty" yaml:"excludeAllTools,omitempty"` +} + +// ConflictResolutionConfig provides configuration for conflict resolution strategies. +// +kubebuilder:object:generate=true +// +gendoc +type ConflictResolutionConfig struct { + // PrefixFormat defines the prefix format for the "prefix" strategy. + // Supports placeholders: {workload}, {workload}_, {workload}. + // +kubebuilder:default="{workload}_" + // +optional + PrefixFormat string `json:"prefixFormat,omitempty" yaml:"prefixFormat,omitempty"` + + // PriorityOrder defines the workload priority order for the "priority" strategy. + // +optional + PriorityOrder []string `json:"priorityOrder,omitempty" yaml:"priorityOrder,omitempty"` +} + +// WorkloadToolConfig defines tool filtering and overrides for a specific workload. +// +kubebuilder:object:generate=true +// +gendoc +type WorkloadToolConfig struct { + // Workload is the name of the backend MCPServer workload. + // +kubebuilder:validation:Required + Workload string `json:"workload" yaml:"workload"` + + // ToolConfigRef references an MCPToolConfig resource for tool filtering and renaming. + // If specified, Filter and Overrides are ignored. + // Only used when running in Kubernetes with the operator. + // +optional + ToolConfigRef *ToolConfigRef `json:"toolConfigRef,omitempty" yaml:"toolConfigRef,omitempty"` + + // Filter is an allow-list of tool names to advertise to MCP clients. + // Tools NOT in this list are hidden from clients (not in tools/list response) + // but remain available in the routing table for composite tools to use. + // This enables selective exposure of backend tools while allowing composite + // workflows to orchestrate all backend capabilities. + // Only used if ToolConfigRef is not specified. + // +optional + Filter []string `json:"filter,omitempty" yaml:"filter,omitempty"` + + // Overrides is an inline map of tool overrides for renaming and description changes. + // Overrides are applied to tools before conflict resolution and affect both + // advertising and routing (the overridden name is used everywhere). + // Only used if ToolConfigRef is not specified. + // +optional + Overrides map[string]*ToolOverride `json:"overrides,omitempty" yaml:"overrides,omitempty"` + + // ExcludeAll hides all tools from this workload from MCP clients when true. + // Hidden tools are NOT advertised in tools/list responses, but they ARE + // available in the routing table for composite tools to use. + // This enables the use case where you want to hide raw backend tools from + // direct client access while exposing curated composite tool workflows. + // +optional + ExcludeAll bool `json:"excludeAll,omitempty" yaml:"excludeAll,omitempty"` +} + +// ToolConfigRef references an MCPToolConfig resource for tool filtering and renaming. +// Only used when running in Kubernetes with the operator. +// +kubebuilder:object:generate=true +// +gendoc +type ToolConfigRef struct { + // Name is the name of the MCPToolConfig resource in the same namespace. + // +kubebuilder:validation:Required + Name string `json:"name" yaml:"name"` +} + +// ToolAnnotationsOverride defines overrides for tool annotation fields. +// All fields use pointers so nil means "don't override" while zero values +// (empty string, false) mean "explicitly set to this value." +// +kubebuilder:object:generate=true +// +gendoc +type ToolAnnotationsOverride struct { + // Title overrides the human-readable title annotation. + // +optional + Title *string `json:"title,omitempty" yaml:"title,omitempty"` + + // ReadOnlyHint overrides the read-only hint annotation. + // +optional + ReadOnlyHint *bool `json:"readOnlyHint,omitempty" yaml:"readOnlyHint,omitempty"` + + // DestructiveHint overrides the destructive hint annotation. + // +optional + DestructiveHint *bool `json:"destructiveHint,omitempty" yaml:"destructiveHint,omitempty"` + + // IdempotentHint overrides the idempotent hint annotation. + // +optional + IdempotentHint *bool `json:"idempotentHint,omitempty" yaml:"idempotentHint,omitempty"` + + // OpenWorldHint overrides the open-world hint annotation. + // +optional + OpenWorldHint *bool `json:"openWorldHint,omitempty" yaml:"openWorldHint,omitempty"` +} + +// ToolOverride defines tool name, description, and annotation overrides. +// +kubebuilder:object:generate=true +// +gendoc +type ToolOverride struct { + // Name is the new tool name (for renaming). + // +optional + Name string `json:"name,omitempty" yaml:"name,omitempty"` + + // Description is the new tool description. + // +optional + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // Annotations overrides specific tool annotation fields. + // Only specified fields are overridden; others pass through from the backend. + // +optional + Annotations *ToolAnnotationsOverride `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// OperationalConfig contains operational settings. +// OperationalConfig defines operational settings like timeouts and health checks. +// +kubebuilder:object:generate=true +// +gendoc +type OperationalConfig struct { + // LogLevel sets the logging level for the Virtual MCP server. + // The only valid value is "debug" to enable debug logging. + // When omitted or empty, the server uses info level logging. + // +kubebuilder:validation:Enum=debug + // +optional + LogLevel string `json:"logLevel,omitempty" yaml:"logLevel,omitempty"` + + // Timeouts configures timeout settings. + // +optional + Timeouts *TimeoutConfig `json:"timeouts,omitempty" yaml:"timeouts,omitempty"` + + // FailureHandling configures failure handling behavior. + // +optional + FailureHandling *FailureHandlingConfig `json:"failureHandling,omitempty" yaml:"failureHandling,omitempty"` +} + +// TimeoutConfig configures timeout settings. +// +kubebuilder:object:generate=true +// +gendoc +type TimeoutConfig struct { + // Default is the default timeout for backend requests. + // +kubebuilder:default="30s" + // +optional + Default Duration `json:"default,omitempty" yaml:"default,omitempty"` + + // PerWorkload defines per-workload timeout overrides. + // +optional + PerWorkload map[string]Duration `json:"perWorkload,omitempty" yaml:"perWorkload,omitempty"` +} + +// FailureHandlingConfig configures failure handling behavior. +// +kubebuilder:object:generate=true +// +gendoc +type FailureHandlingConfig struct { + // HealthCheckInterval is the interval between health checks. + // +kubebuilder:default="30s" + // +optional + HealthCheckInterval Duration `json:"healthCheckInterval,omitempty" yaml:"healthCheckInterval,omitempty"` + + // UnhealthyThreshold is the number of consecutive failures before marking unhealthy. + // +kubebuilder:default=3 + // +optional + UnhealthyThreshold int `json:"unhealthyThreshold,omitempty" yaml:"unhealthyThreshold,omitempty"` + + // HealthCheckTimeout is the maximum duration for a single health check operation. + // Should be less than HealthCheckInterval to prevent checks from queuing up. + // +kubebuilder:default="10s" + // +optional + HealthCheckTimeout Duration `json:"healthCheckTimeout,omitempty" yaml:"healthCheckTimeout,omitempty"` + + // StatusReportingInterval is the interval for reporting status updates to Kubernetes. + // This controls how often the vMCP runtime reports backend health and phase changes. + // Lower values provide faster status updates but increase API server load. + // +kubebuilder:default="30s" + // +optional + StatusReportingInterval Duration `json:"statusReportingInterval,omitempty" yaml:"statusReportingInterval,omitempty"` + + // PartialFailureMode defines behavior when some backends are unavailable. + // - fail: Fail entire request if any backend is unavailable + // - best_effort: Continue with available backends + // +kubebuilder:validation:Enum=fail;best_effort + // +kubebuilder:default=fail + // +optional + PartialFailureMode string `json:"partialFailureMode,omitempty" yaml:"partialFailureMode,omitempty"` + + // CircuitBreaker configures circuit breaker behavior. + // +optional + CircuitBreaker *CircuitBreakerConfig `json:"circuitBreaker,omitempty" yaml:"circuitBreaker,omitempty"` +} + +// CircuitBreakerConfig configures circuit breaker behavior. +// +kubebuilder:object:generate=true +// +gendoc +type CircuitBreakerConfig struct { + // Enabled controls whether circuit breaker is enabled. + // +kubebuilder:default=false + // +optional + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + + // FailureThreshold is the number of failures before opening the circuit. + // Must be >= 1. + // +kubebuilder:default=5 + // +kubebuilder:validation:Minimum=1 + // +optional + FailureThreshold int `json:"failureThreshold,omitempty" yaml:"failureThreshold,omitempty"` + + // Timeout is the duration to wait before attempting to close the circuit. + // Must be >= 1s to prevent thrashing. + // +kubebuilder:default="60s" + // +kubebuilder:validation:XValidation:rule="self == '' || duration(self) >= duration('1s')",message="timeout must be >= 1s" + // +optional + Timeout Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` +} + +// CompositeToolConfig defines a composite tool workflow. +// This matches the YAML structure from the proposal (lines 173-255). +// +kubebuilder:object:generate=true +// +gendoc +type CompositeToolConfig struct { + // Name is the workflow name (unique identifier). + Name string `json:"name" yaml:"name"` + + // Description describes what the workflow does. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // Parameters defines input parameter schema in JSON Schema format. + // Should be a JSON Schema object with "type": "object" and "properties". + // Example: + // { + // "type": "object", + // "properties": { + // "param1": {"type": "string", "default": "value"}, + // "param2": {"type": "integer"} + // }, + // "required": ["param2"] + // } + // + // We use json.Map rather than a typed struct because JSON Schema is highly + // flexible with many optional fields (default, enum, minimum, maximum, pattern, + // items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map + // allows full JSON Schema compatibility without needing to define every possible + // field, and matches how the MCP SDK handles inputSchema. + // +optional + Parameters thvjson.Map `json:"parameters,omitempty" yaml:"parameters,omitempty"` + + // Timeout is the maximum workflow execution time. + Timeout Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` + + // Steps are the workflow steps to execute. + Steps []WorkflowStepConfig `json:"steps" yaml:"steps"` + + // Output defines the structured output schema for this workflow. + // If not specified, the workflow returns the last step's output (backward compatible). + // +optional + Output *OutputConfig `json:"output,omitempty" yaml:"output,omitempty"` +} + +// CompositeToolRef defines a reference to a VirtualMCPCompositeToolDefinition resource. +// The referenced resource must be in the same namespace as the VirtualMCPServer. +// +kubebuilder:object:generate=true +// +gendoc +type CompositeToolRef struct { + // Name is the name of the VirtualMCPCompositeToolDefinition resource in the same namespace. + // +kubebuilder:validation:Required + Name string `json:"name" yaml:"name"` +} + +// WorkflowStepConfig defines a single workflow step. +// This matches the proposal's step configuration (lines 180-255). +// +kubebuilder:object:generate=true +// +gendoc +type WorkflowStepConfig struct { + // ID is the unique identifier for this step. + // +kubebuilder:validation:Required + ID string `json:"id" yaml:"id"` + + // Type is the step type (tool, elicitation, etc.) + // +kubebuilder:validation:Enum=tool;elicitation;forEach + // +kubebuilder:default=tool + // +optional + Type string `json:"type,omitempty" yaml:"type,omitempty"` + + // Tool is the tool to call (format: "workload.tool_name") + // Only used when Type is "tool" + // +optional + Tool string `json:"tool,omitempty" yaml:"tool,omitempty"` + + // Arguments is a map of argument values with template expansion support. + // Supports Go template syntax with .params and .steps for string values. + // Non-string values (integers, booleans, arrays, objects) are passed as-is. + // Note: the templating is only supported on the first level of the key-value pairs. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + Arguments thvjson.Map `json:"arguments,omitempty" yaml:"arguments,omitempty"` + + // Condition is a template expression that determines if the step should execute + // +optional + Condition string `json:"condition,omitempty" yaml:"condition,omitempty"` + + // DependsOn lists step IDs that must complete before this step + // +optional + DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` + + // OnError defines error handling behavior + // +optional + OnError *StepErrorHandling `json:"onError,omitempty" yaml:"onError,omitempty"` + + // Message is the elicitation message + // Only used when Type is "elicitation" + // +optional + Message string `json:"message,omitempty" yaml:"message,omitempty"` + + // Schema defines the expected response schema for elicitation + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + Schema thvjson.Map `json:"schema,omitempty" yaml:"schema,omitempty"` + + // Timeout is the maximum execution time for this step + // +optional + Timeout Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"` + + // OnDecline defines the action to take when the user explicitly declines the elicitation + // Only used when Type is "elicitation" + // +optional + OnDecline *ElicitationResponseConfig `json:"onDecline,omitempty" yaml:"onDecline,omitempty"` + + // OnCancel defines the action to take when the user cancels/dismisses the elicitation + // Only used when Type is "elicitation" + // +optional + OnCancel *ElicitationResponseConfig `json:"onCancel,omitempty" yaml:"onCancel,omitempty"` + + // DefaultResults provides fallback output values when this step is skipped + // (due to condition evaluating to false) or fails (when onError.action is "continue"). + // Each key corresponds to an output field name referenced by downstream steps. + // Required if the step may be skipped AND downstream steps reference this step's output. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + DefaultResults thvjson.Map `json:"defaultResults,omitempty" yaml:"defaultResults,omitempty"` + + // Collection is a Go template expression that resolves to a JSON array or a slice. + // Only used when Type is "forEach". + // +optional + Collection string `json:"collection,omitempty" yaml:"collection,omitempty"` + + // ItemVar is the variable name used to reference the current item in forEach templates. + // Defaults to "item" if not specified. + // Only used when Type is "forEach". + // +optional + ItemVar string `json:"itemVar,omitempty" yaml:"itemVar,omitempty"` + + // MaxParallel limits the number of concurrent iterations in a forEach step. + // Defaults to the DAG executor's maxParallel (10). + // Only used when Type is "forEach". + // +optional + MaxParallel int `json:"maxParallel,omitempty" yaml:"maxParallel,omitempty"` + + // MaxIterations limits the number of items that can be iterated over. + // Defaults to 100, hard cap at 1000. + // Only used when Type is "forEach". + // +optional + MaxIterations int `json:"maxIterations,omitempty" yaml:"maxIterations,omitempty"` + + // InnerStep defines the step to execute for each item in the collection. + // Only used when Type is "forEach". Only tool-type inner steps are supported. + // +optional + // +kubebuilder:validation:Type=object + // +kubebuilder:pruning:PreserveUnknownFields + InnerStep *WorkflowStepConfig `json:"step,omitempty" yaml:"step,omitempty"` +} + +// StepErrorHandling defines error handling behavior for workflow steps. +// +kubebuilder:object:generate=true +// +gendoc +type StepErrorHandling struct { + // Action defines the action to take on error + // +kubebuilder:validation:Enum=abort;continue;retry + // +kubebuilder:default=abort + // +optional + Action string `json:"action,omitempty" yaml:"action,omitempty"` + + // RetryCount is the maximum number of retries + // Only used when Action is "retry" + // +optional + RetryCount int `json:"retryCount,omitempty" yaml:"retryCount,omitempty"` + + // RetryDelay is the delay between retry attempts + // Only used when Action is "retry" + // +optional + RetryDelay Duration `json:"retryDelay,omitempty" yaml:"retryDelay,omitempty"` +} + +// ElicitationResponseConfig defines how to handle user responses to elicitation requests. +// +kubebuilder:object:generate=true +// +gendoc +type ElicitationResponseConfig struct { + // Action defines the action to take when the user declines or cancels + // - skip_remaining: Skip remaining steps in the workflow + // - abort: Abort the entire workflow execution + // - continue: Continue to the next step + // +kubebuilder:validation:Enum=skip_remaining;abort;continue + // +kubebuilder:default=abort + // +optional + Action string `json:"action,omitempty" yaml:"action,omitempty"` +} + +// OutputConfig defines the structured output schema for a composite tool workflow. +// This follows the same pattern as the Parameters field, defining both the +// MCP output schema (type, description) and runtime value construction (value, default). +// +kubebuilder:object:generate=true +// +gendoc +type OutputConfig struct { + // Properties defines the output properties. + // Map key is the property name, value is the property definition. + Properties map[string]OutputProperty `json:"properties" yaml:"properties"` + + // Required lists property names that must be present in the output. + // +optional + Required []string `json:"required,omitempty" yaml:"required,omitempty"` +} + +// OutputProperty defines a single output property. +// For non-object types, Value is required. +// For object types, either Value or Properties must be specified (but not both). +// +kubebuilder:object:generate=true +// +gendoc +type OutputProperty struct { + // Type is the JSON Schema type: "string", "integer", "number", "boolean", "object", "array" + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=string;integer;number;boolean;object;array + Type string `json:"type" yaml:"type"` + + // Description is a human-readable description exposed to clients and models + // +optional + Description string `json:"description" yaml:"description"` + + // Value is a template string for constructing the runtime value. + // For object types, this can be a JSON string that will be deserialized. + // Supports template syntax: {{.steps.step_id.output.field}}, {{.params.param_name}} + // +optional + Value string `json:"value,omitempty" yaml:"value,omitempty"` + + // Properties defines nested properties for object types. + // Each nested property has full metadata (type, description, value/properties). + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + // +kubebuilder:validation:Schemaless + Properties map[string]OutputProperty `json:"properties,omitempty" yaml:"properties,omitempty"` + + // Default is the fallback value if template expansion fails. + // Type coercion is applied to match the declared Type. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + Default thvjson.Any `json:"default,omitempty" yaml:"default,omitempty"` +} + +// OptimizerConfig configures the MCP optimizer. +// When enabled, vMCP exposes only find_tool and call_tool operations to clients +// instead of all backend tools directly. +// +kubebuilder:object:generate=true +// +gendoc +type OptimizerConfig struct { + // EmbeddingService is the full base URL of the embedding service endpoint + // (e.g., http://my-embedding.default.svc.cluster.local:8080) for semantic + // tool discovery. + // + // In a Kubernetes environment, it is more convenient to use the + // VirtualMCPServerSpec.EmbeddingServerRef field instead of setting this + // directly. EmbeddingServerRef references an EmbeddingServer CRD by name, + // and the operator automatically resolves the referenced resource's + // Status.URL to populate this field. This provides managed lifecycle + // (the operator watches the EmbeddingServer for readiness and URL changes) + // and avoids hardcoding service URLs in the config. If both + // EmbeddingServerRef and this field are set, EmbeddingServerRef takes + // precedence and this value is overridden with a warning. + // +optional + EmbeddingService string `json:"embeddingService,omitempty" yaml:"embeddingService,omitempty"` + + // EmbeddingServiceTimeout is the HTTP request timeout for calls to the embedding service. + // Defaults to 30s if not specified. + // +kubebuilder:default="30s" + // +optional + EmbeddingServiceTimeout Duration `json:"embeddingServiceTimeout,omitempty" yaml:"embeddingServiceTimeout,omitempty"` + + // MaxToolsToReturn is the maximum number of tool results returned by a search query. + // Defaults to 8 if not specified or zero. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=50 + // +optional + MaxToolsToReturn int `json:"maxToolsToReturn,omitempty" yaml:"maxToolsToReturn,omitempty"` + + // HybridSearchSemanticRatio controls the balance between semantic (meaning-based) + // and keyword search results. 0.0 = all keyword, 1.0 = all semantic. + // Defaults to "0.5" if not specified or empty. + // Serialized as a string because CRDs do not support float types portably. + // +kubebuilder:validation:Pattern=`^([0-9]*[.])?[0-9]+$` + // +optional + HybridSearchSemanticRatio string `json:"hybridSearchSemanticRatio,omitempty" yaml:"hybridSearchSemanticRatio,omitempty"` + + // SemanticDistanceThreshold is the maximum distance for semantic search results. + // Results exceeding this threshold are filtered out from semantic search. + // This threshold does not apply to keyword search. + // Range: 0 = identical, 2 = completely unrelated. + // Defaults to "1.0" if not specified or empty. + // Serialized as a string because CRDs do not support float types portably. + // +kubebuilder:validation:Pattern=`^([0-9]*[.])?[0-9]+$` + // +optional + SemanticDistanceThreshold string `json:"semanticDistanceThreshold,omitempty" yaml:"semanticDistanceThreshold,omitempty"` +} + +// SessionStorageConfig configures session storage for stateful horizontal scaling. +// The Redis password is not stored here; it is injected as the THV_SESSION_REDIS_PASSWORD +// environment variable by the operator when spec.sessionStorage.passwordRef is set. +// +kubebuilder:object:generate=true +// +gendoc +type SessionStorageConfig struct { + // Provider is the session storage backend type. + // +kubebuilder:validation:Enum=memory;redis + // +kubebuilder:validation:Required + Provider string `json:"provider" yaml:"provider"` + + // Address is the Redis server address (required when provider is redis). + // +optional + Address string `json:"address,omitempty" yaml:"address,omitempty"` + + // DB is the Redis database number. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:default=0 + // +optional + DB int32 `json:"db,omitempty" yaml:"db,omitempty"` + + // KeyPrefix is an optional prefix for all Redis keys used by ToolHive. + // +optional + KeyPrefix string `json:"keyPrefix,omitempty" yaml:"keyPrefix,omitempty"` +} diff --git a/cmd/thv-operator/pkg/vmcpcrd/zz_generated.deepcopy.go b/cmd/thv-operator/pkg/vmcpcrd/zz_generated.deepcopy.go new file mode 100644 index 0000000000..9a66111805 --- /dev/null +++ b/cmd/thv-operator/pkg/vmcpcrd/zz_generated.deepcopy.go @@ -0,0 +1,687 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 Stacklok + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package vmcpcrd + +import ( + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/ratelimit/types" + "github.com/stacklok/toolhive/pkg/telemetry" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AggregationConfig) DeepCopyInto(out *AggregationConfig) { + *out = *in + if in.ConflictResolutionConfig != nil { + in, out := &in.ConflictResolutionConfig, &out.ConflictResolutionConfig + *out = new(ConflictResolutionConfig) + (*in).DeepCopyInto(*out) + } + if in.Tools != nil { + in, out := &in.Tools, &out.Tools + *out = make([]*WorkloadToolConfig, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(WorkloadToolConfig) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AggregationConfig. +func (in *AggregationConfig) DeepCopy() *AggregationConfig { + if in == nil { + return nil + } + out := new(AggregationConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthzConfig) DeepCopyInto(out *AuthzConfig) { + *out = *in + if in.Policies != nil { + in, out := &in.Policies, &out.Policies + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthzConfig. +func (in *AuthzConfig) DeepCopy() *AuthzConfig { + if in == nil { + return nil + } + out := new(AuthzConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CircuitBreakerConfig) DeepCopyInto(out *CircuitBreakerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CircuitBreakerConfig. +func (in *CircuitBreakerConfig) DeepCopy() *CircuitBreakerConfig { + if in == nil { + return nil + } + out := new(CircuitBreakerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CompositeToolConfig) DeepCopyInto(out *CompositeToolConfig) { + *out = *in + in.Parameters.DeepCopyInto(&out.Parameters) + if in.Steps != nil { + in, out := &in.Steps, &out.Steps + *out = make([]WorkflowStepConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Output != nil { + in, out := &in.Output, &out.Output + *out = new(OutputConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CompositeToolConfig. +func (in *CompositeToolConfig) DeepCopy() *CompositeToolConfig { + if in == nil { + return nil + } + out := new(CompositeToolConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CompositeToolRef) DeepCopyInto(out *CompositeToolRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CompositeToolRef. +func (in *CompositeToolRef) DeepCopy() *CompositeToolRef { + if in == nil { + return nil + } + out := new(CompositeToolRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Config) DeepCopyInto(out *Config) { + *out = *in + if in.Backends != nil { + in, out := &in.Backends, &out.Backends + *out = make([]StaticBackendConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.IncomingAuth != nil { + in, out := &in.IncomingAuth, &out.IncomingAuth + *out = new(IncomingAuthConfig) + (*in).DeepCopyInto(*out) + } + if in.OutgoingAuth != nil { + in, out := &in.OutgoingAuth, &out.OutgoingAuth + *out = new(OutgoingAuthConfig) + (*in).DeepCopyInto(*out) + } + if in.Aggregation != nil { + in, out := &in.Aggregation, &out.Aggregation + *out = new(AggregationConfig) + (*in).DeepCopyInto(*out) + } + if in.CompositeTools != nil { + in, out := &in.CompositeTools, &out.CompositeTools + *out = make([]CompositeToolConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.CompositeToolRefs != nil { + in, out := &in.CompositeToolRefs, &out.CompositeToolRefs + *out = make([]CompositeToolRef, len(*in)) + copy(*out, *in) + } + if in.Operational != nil { + in, out := &in.Operational, &out.Operational + *out = new(OperationalConfig) + (*in).DeepCopyInto(*out) + } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Telemetry != nil { + in, out := &in.Telemetry, &out.Telemetry + *out = new(telemetry.Config) + (*in).DeepCopyInto(*out) + } + if in.Audit != nil { + in, out := &in.Audit, &out.Audit + *out = new(audit.Config) + (*in).DeepCopyInto(*out) + } + if in.Optimizer != nil { + in, out := &in.Optimizer, &out.Optimizer + *out = new(OptimizerConfig) + **out = **in + } + if in.SessionStorage != nil { + in, out := &in.SessionStorage, &out.SessionStorage + *out = new(SessionStorageConfig) + **out = **in + } + if in.RateLimiting != nil { + in, out := &in.RateLimiting, &out.RateLimiting + *out = new(types.RateLimitConfig) + (*in).DeepCopyInto(*out) + } + if in.PassthroughHeaders != nil { + in, out := &in.PassthroughHeaders, &out.PassthroughHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. +func (in *Config) DeepCopy() *Config { + if in == nil { + return nil + } + out := new(Config) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConflictResolutionConfig) DeepCopyInto(out *ConflictResolutionConfig) { + *out = *in + if in.PriorityOrder != nil { + in, out := &in.PriorityOrder, &out.PriorityOrder + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConflictResolutionConfig. +func (in *ConflictResolutionConfig) DeepCopy() *ConflictResolutionConfig { + if in == nil { + return nil + } + out := new(ConflictResolutionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ElicitationResponseConfig) DeepCopyInto(out *ElicitationResponseConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ElicitationResponseConfig. +func (in *ElicitationResponseConfig) DeepCopy() *ElicitationResponseConfig { + if in == nil { + return nil + } + out := new(ElicitationResponseConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FailureHandlingConfig) DeepCopyInto(out *FailureHandlingConfig) { + *out = *in + if in.CircuitBreaker != nil { + in, out := &in.CircuitBreaker, &out.CircuitBreaker + *out = new(CircuitBreakerConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailureHandlingConfig. +func (in *FailureHandlingConfig) DeepCopy() *FailureHandlingConfig { + if in == nil { + return nil + } + out := new(FailureHandlingConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { + *out = *in + if in.OIDC != nil { + in, out := &in.OIDC, &out.OIDC + *out = new(OIDCConfig) + (*in).DeepCopyInto(*out) + } + if in.Authz != nil { + in, out := &in.Authz, &out.Authz + *out = new(AuthzConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IncomingAuthConfig. +func (in *IncomingAuthConfig) DeepCopy() *IncomingAuthConfig { + if in == nil { + return nil + } + out := new(IncomingAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OIDCConfig) DeepCopyInto(out *OIDCConfig) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCConfig. +func (in *OIDCConfig) DeepCopy() *OIDCConfig { + if in == nil { + return nil + } + out := new(OIDCConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperationalConfig) DeepCopyInto(out *OperationalConfig) { + *out = *in + if in.Timeouts != nil { + in, out := &in.Timeouts, &out.Timeouts + *out = new(TimeoutConfig) + (*in).DeepCopyInto(*out) + } + if in.FailureHandling != nil { + in, out := &in.FailureHandling, &out.FailureHandling + *out = new(FailureHandlingConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationalConfig. +func (in *OperationalConfig) DeepCopy() *OperationalConfig { + if in == nil { + return nil + } + out := new(OperationalConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptimizerConfig) DeepCopyInto(out *OptimizerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptimizerConfig. +func (in *OptimizerConfig) DeepCopy() *OptimizerConfig { + if in == nil { + return nil + } + out := new(OptimizerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OutgoingAuthConfig) DeepCopyInto(out *OutgoingAuthConfig) { + *out = *in + if in.Default != nil { + in, out := &in.Default, &out.Default + *out = new(authtypes.BackendAuthStrategy) + (*in).DeepCopyInto(*out) + } + if in.Backends != nil { + in, out := &in.Backends, &out.Backends + *out = make(map[string]*authtypes.BackendAuthStrategy, len(*in)) + for key, val := range *in { + var outVal *authtypes.BackendAuthStrategy + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = new(authtypes.BackendAuthStrategy) + (*in).DeepCopyInto(*out) + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutgoingAuthConfig. +func (in *OutgoingAuthConfig) DeepCopy() *OutgoingAuthConfig { + if in == nil { + return nil + } + out := new(OutgoingAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OutputConfig) DeepCopyInto(out *OutputConfig) { + *out = *in + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make(map[string]OutputProperty, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Required != nil { + in, out := &in.Required, &out.Required + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutputConfig. +func (in *OutputConfig) DeepCopy() *OutputConfig { + if in == nil { + return nil + } + out := new(OutputConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OutputProperty) DeepCopyInto(out *OutputProperty) { + *out = *in + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make(map[string]OutputProperty, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + in.Default.DeepCopyInto(&out.Default) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutputProperty. +func (in *OutputProperty) DeepCopy() *OutputProperty { + if in == nil { + return nil + } + out := new(OutputProperty) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SessionStorageConfig) DeepCopyInto(out *SessionStorageConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SessionStorageConfig. +func (in *SessionStorageConfig) DeepCopy() *SessionStorageConfig { + if in == nil { + return nil + } + out := new(SessionStorageConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StaticBackendConfig) DeepCopyInto(out *StaticBackendConfig) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StaticBackendConfig. +func (in *StaticBackendConfig) DeepCopy() *StaticBackendConfig { + if in == nil { + return nil + } + out := new(StaticBackendConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepErrorHandling) DeepCopyInto(out *StepErrorHandling) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepErrorHandling. +func (in *StepErrorHandling) DeepCopy() *StepErrorHandling { + if in == nil { + return nil + } + out := new(StepErrorHandling) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeoutConfig) DeepCopyInto(out *TimeoutConfig) { + *out = *in + if in.PerWorkload != nil { + in, out := &in.PerWorkload, &out.PerWorkload + *out = make(map[string]Duration, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeoutConfig. +func (in *TimeoutConfig) DeepCopy() *TimeoutConfig { + if in == nil { + return nil + } + out := new(TimeoutConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolAnnotationsOverride) DeepCopyInto(out *ToolAnnotationsOverride) { + *out = *in + if in.Title != nil { + in, out := &in.Title, &out.Title + *out = new(string) + **out = **in + } + if in.ReadOnlyHint != nil { + in, out := &in.ReadOnlyHint, &out.ReadOnlyHint + *out = new(bool) + **out = **in + } + if in.DestructiveHint != nil { + in, out := &in.DestructiveHint, &out.DestructiveHint + *out = new(bool) + **out = **in + } + if in.IdempotentHint != nil { + in, out := &in.IdempotentHint, &out.IdempotentHint + *out = new(bool) + **out = **in + } + if in.OpenWorldHint != nil { + in, out := &in.OpenWorldHint, &out.OpenWorldHint + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolAnnotationsOverride. +func (in *ToolAnnotationsOverride) DeepCopy() *ToolAnnotationsOverride { + if in == nil { + return nil + } + out := new(ToolAnnotationsOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolConfigRef) DeepCopyInto(out *ToolConfigRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolConfigRef. +func (in *ToolConfigRef) DeepCopy() *ToolConfigRef { + if in == nil { + return nil + } + out := new(ToolConfigRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolOverride) DeepCopyInto(out *ToolOverride) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = new(ToolAnnotationsOverride) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolOverride. +func (in *ToolOverride) DeepCopy() *ToolOverride { + if in == nil { + return nil + } + out := new(ToolOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowStepConfig) DeepCopyInto(out *WorkflowStepConfig) { + *out = *in + in.Arguments.DeepCopyInto(&out.Arguments) + if in.DependsOn != nil { + in, out := &in.DependsOn, &out.DependsOn + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.OnError != nil { + in, out := &in.OnError, &out.OnError + *out = new(StepErrorHandling) + **out = **in + } + in.Schema.DeepCopyInto(&out.Schema) + if in.OnDecline != nil { + in, out := &in.OnDecline, &out.OnDecline + *out = new(ElicitationResponseConfig) + **out = **in + } + if in.OnCancel != nil { + in, out := &in.OnCancel, &out.OnCancel + *out = new(ElicitationResponseConfig) + **out = **in + } + in.DefaultResults.DeepCopyInto(&out.DefaultResults) + if in.InnerStep != nil { + in, out := &in.InnerStep, &out.InnerStep + *out = new(WorkflowStepConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowStepConfig. +func (in *WorkflowStepConfig) DeepCopy() *WorkflowStepConfig { + if in == nil { + return nil + } + out := new(WorkflowStepConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadToolConfig) DeepCopyInto(out *WorkloadToolConfig) { + *out = *in + if in.ToolConfigRef != nil { + in, out := &in.ToolConfigRef, &out.ToolConfigRef + *out = new(ToolConfigRef) + **out = **in + } + if in.Filter != nil { + in, out := &in.Filter, &out.Filter + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Overrides != nil { + in, out := &in.Overrides, &out.Overrides + *out = make(map[string]*ToolOverride, len(*in)) + for key, val := range *in { + var outVal *ToolOverride + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = new(ToolOverride) + (*in).DeepCopyInto(*out) + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadToolConfig. +func (in *WorkloadToolConfig) DeepCopy() *WorkloadToolConfig { + if in == nil { + return nil + } + out := new(WorkloadToolConfig) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go index f8d18f043e..37c1c4c000 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go @@ -14,6 +14,7 @@ import ( "sigs.k8s.io/yaml" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -103,7 +104,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ @@ -281,7 +282,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ @@ -417,7 +418,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ @@ -543,7 +544,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ @@ -683,7 +684,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfig_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfig_cel_test.go index 72b869b932..cfae4c1845 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfig_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfig_cel_test.go @@ -9,7 +9,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) // newVirtualMCPServerWithIncomingAuth builds a minimal VirtualMCPServer whose @@ -29,7 +29,7 @@ func newVirtualMCPServerWithIncomingAuth( AuthzConfig: authzConfig, AuthzConfigRef: authzConfigRef, }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: "test-group", }, }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfigref_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfigref_integration_test.go index 69893ed5a6..e349b7e9d1 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfigref_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_authzconfigref_integration_test.go @@ -18,6 +18,7 @@ import ( "sigs.k8s.io/yaml" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -73,7 +74,7 @@ var _ = Describe("VirtualMCPServer AuthzConfigRef Integration", Label("k8s", "au ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, - Config: vmcpconfig.Config{Group: groupName}, + Config: vmcpcrd.Config{Group: groupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: authzRefName}, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go index a22493c0fe..a07b285ed7 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go @@ -13,8 +13,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tests", func() { @@ -73,9 +73,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: compositeToolDefName}, }, }, @@ -127,17 +127,17 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "test-workflow", Description: "Test workflow for integration test", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Tool: "tool1", }, }, - Output: &vmcpconfig.OutputConfig{ - Properties: map[string]vmcpconfig.OutputProperty{ + Output: &vmcpcrd.OutputConfig{ + Properties: map[string]vmcpcrd.OutputProperty{ "result": { Type: "string", Description: "The workflow result", @@ -237,10 +237,10 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "test-workflow-update", Description: "Initial description", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Tool: "tool1", @@ -259,9 +259,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: compositeToolDefName}, }, }, @@ -382,7 +382,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -434,10 +434,10 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "unrelated-workflow", Description: "Workflow not referenced by VirtualMCPServer", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "step1", Tool: "tool1", diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go index 9b52de1eb6..1fa5882e89 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go @@ -13,8 +13,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { @@ -70,17 +70,17 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "interactive_workflow", Description: "Workflow with user interactions via elicitations", - Timeout: vmcpconfig.Duration(15 * time.Minute), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(15 * time.Minute), + Steps: []vmcpcrd.WorkflowStepConfig{ // Step 1: Tool call { ID: "prepare", Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "echo", - Timeout: vmcpconfig.Duration(1 * time.Minute), + Timeout: vmcpcrd.Duration(1 * time.Minute), }, // Step 2: Elicitation with OnDecline and OnCancel handlers { @@ -89,11 +89,11 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Message: "Proceed with deployment?", Schema: thvjson.NewMap(map[string]any{"type": "object", "properties": map[string]any{"proceed": map[string]any{"type": "boolean"}}}), DependsOn: []string{"prepare"}, - Timeout: vmcpconfig.Duration(5 * time.Minute), - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + Timeout: vmcpcrd.Duration(5 * time.Minute), + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "skip_remaining", }, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, }, @@ -104,11 +104,11 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Message: "Select target environment", Schema: thvjson.NewMap(map[string]any{"type": "object", "properties": map[string]any{"environment": map[string]any{"type": "string", "enum": []any{"staging", "production"}}}}), DependsOn: []string{"confirm_deploy"}, - Timeout: vmcpconfig.Duration(5 * time.Minute), - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + Timeout: vmcpcrd.Duration(5 * time.Minute), + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "continue", }, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, }, @@ -118,7 +118,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "deploy_app", DependsOn: []string{"select_env"}, - Timeout: vmcpconfig.Duration(2 * time.Minute), + Timeout: vmcpcrd.Duration(2 * time.Minute), }, }, }, @@ -134,9 +134,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: compositeToolDefName}, }, }, @@ -281,20 +281,20 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "all_handlers_workflow", Description: "Test all valid elicitation handler actions", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ // Test skip_remaining { ID: "elicit_skip", Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test skip_remaining", Schema: thvjson.NewMap(map[string]any{"type": "object"}), - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "skip_remaining", }, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "skip_remaining", }, }, @@ -304,10 +304,10 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test abort", Schema: thvjson.NewMap(map[string]any{"type": "object"}), - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, }, @@ -317,10 +317,10 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test continue", Schema: thvjson.NewMap(map[string]any{"type": "object"}), - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "continue", }, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "continue", }, }, @@ -338,9 +338,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: compositeToolDefName}, }, }, @@ -465,10 +465,10 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "mixed_steps_workflow", Description: "Workflow with alternating tool calls and elicitations", - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ // Tool call { ID: "tool1", @@ -482,7 +482,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Message: "Confirm step 1?", Schema: thvjson.NewMap(map[string]any{"type": "object"}), DependsOn: []string{"tool1"}, - OnDecline: &vmcpconfig.ElicitationResponseConfig{ + OnDecline: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, }, @@ -500,7 +500,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Message: "Confirm step 2?", Schema: thvjson.NewMap(map[string]any{"type": "object"}), DependsOn: []string{"tool2"}, - OnCancel: &vmcpconfig.ElicitationResponseConfig{ + OnCancel: &vmcpcrd.ElicitationResponseConfig{ Action: "abort", }, }, @@ -525,9 +525,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ {Name: compositeToolDefName}, }, }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go index 97ff176877..a467dee9ea 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go @@ -13,7 +13,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", func() { @@ -109,7 +109,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_imagepullsecrets_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_imagepullsecrets_integration_test.go index f4e1d9897e..7cef3371db 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_imagepullsecrets_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_imagepullsecrets_integration_test.go @@ -18,7 +18,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) // extractSecretNames returns just the Name fields from a list of LocalObjectReferences, @@ -73,7 +73,7 @@ var _ = Describe("VirtualMCPServer ImagePullSecrets Integration Tests", ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, ImagePullSecrets: []corev1.LocalObjectReference{ {Name: "registry-creds-1"}, @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer ImagePullSecrets Integration Tests", ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, ImagePullSecrets: []corev1.LocalObjectReference{ {Name: "secret-a"}, @@ -239,7 +239,7 @@ var _ = Describe("VirtualMCPServer ImagePullSecrets Integration Tests", ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, // "shared" appears in both sources to exercise overlap; // "explicit-only" is unique to spec.imagePullSecrets; diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go index 0b981b5132..1e4fdcd5e4 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go @@ -17,7 +17,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { @@ -73,7 +73,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -213,7 +213,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -317,7 +317,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{Group: mcpGroupName}, + Config: vmcpcrd.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go index 491f11b854..84afa71b98 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) var _ = Describe("VirtualMCPServer Replicas Integration Tests", @@ -61,7 +61,7 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-replicas"}, - Config: vmcpconfig.Config{Group: "test-group-replicas"}, + Config: vmcpcrd.Config{Group: "test-group-replicas"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -115,7 +115,7 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-nil-replicas"}, - Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, + Config: vmcpcrd.Config{Group: "test-group-nil-replicas"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go index 2b4a3cfff4..28d1592c89 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" ) func newVirtualMCPServerWithSessionStorage(name string, ss *mcpv1beta1.SessionStorageConfig) *mcpv1beta1.VirtualMCPServer { @@ -26,7 +26,7 @@ func newVirtualMCPServerWithSessionStorage(name string, ss *mcpv1beta1.SessionSt IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: "test-group", }, SessionStorage: ss, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go index 84a06f8ed9..c1d92a765a 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/yaml" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -86,7 +87,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-hash"}, - Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, + Config: vmcpcrd.Config{Group: "test-group-telemetry-hash"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -221,7 +222,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-update"}, - Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, + Config: vmcpcrd.Config{Group: "test-group-telemetry-update"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, @@ -331,7 +332,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-notfound"}, - Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, + Config: vmcpcrd.Config{Group: "test-group-telemetry-notfound"}, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 3cf5f1893d..323a7ba79f 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -93,6 +93,7 @@ using HeaderInjection or TokenExchange fields based on the Type field. _Appears in:_ +- [pkg.vmcpcrd.OutgoingAuthConfig](#pkgvmcpcrdoutgoingauthconfig) - [vmcp.config.OutgoingAuthConfig](#vmcpconfigoutgoingauthconfig) | Field | Description | Default | Validation | @@ -267,7 +268,6 @@ This matches the YAML structure from the proposal (lines 173-255). _Appears in:_ - [vmcp.config.Config](#vmcpconfigconfig) -- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -296,40 +296,6 @@ _Appears in:_ | `name` _string_ | Name is the name of the VirtualMCPCompositeToolDefinition resource in the same namespace. | | Required: \{\}
| -#### vmcp.config.Config - - - -Config is the unified configuration model for Virtual MCP Server. -This is platform-agnostic and used by both CLI and Kubernetes deployments. - -Platform-specific adapters (CLI YAML loader, Kubernetes CRD converter) -transform their native formats into this model. - -_Validation:_ -- Type: object - -_Appears in:_ -- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is the virtual MCP server name. | | Optional: \{\}
| -| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.
In standalone CLI mode, this is set from the YAML config file.
In Kubernetes, the operator populates this from spec.groupRef during conversion. | | Optional: \{\}
| -| `backends` _[vmcp.config.StaticBackendConfig](#vmcpconfigstaticbackendconfig) array_ | Backends defines pre-configured backend servers for static mode.
When OutgoingAuth.Source is "inline", this field contains the full list of backend
servers with their URLs and transport types, eliminating the need for K8s API access.
When OutgoingAuth.Source is "discovered", this field is empty and backends are
discovered at runtime via Kubernetes API. | | Optional: \{\}
| -| `incomingAuth` _[vmcp.config.IncomingAuthConfig](#vmcpconfigincomingauthconfig)_ | IncomingAuth configures how clients authenticate to the virtual MCP server.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. | | Optional: \{\}
| -| `outgoingAuth` _[vmcp.config.OutgoingAuthConfig](#vmcpconfigoutgoingauthconfig)_ | OutgoingAuth configures how the virtual MCP server authenticates to backends.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. | | Optional: \{\}
| -| `aggregation` _[vmcp.config.AggregationConfig](#vmcpconfigaggregationconfig)_ | Aggregation defines tool aggregation and conflict resolution strategies.
Supports ToolConfigRef for Kubernetes-native MCPToolConfig resource references. | | Optional: \{\}
| -| `compositeTools` _[vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) array_ | CompositeTools defines inline composite tool workflows.
Full workflow definitions are embedded in the configuration.
For Kubernetes, complex workflows can also reference VirtualMCPCompositeToolDefinition CRDs. | | Optional: \{\}
| -| `compositeToolRefs` _[vmcp.config.CompositeToolRef](#vmcpconfigcompositetoolref) array_ | CompositeToolRefs references VirtualMCPCompositeToolDefinition resources
for complex, reusable workflows. Only applicable when running in Kubernetes.
Referenced resources must be in the same namespace as the VirtualMCPServer. | | Optional: \{\}
| -| `operational` _[vmcp.config.OperationalConfig](#vmcpconfigoperationalconfig)_ | Operational configures operational settings. | | | -| `metadata` _object (keys:string, values:string)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `telemetry` _[pkg.telemetry.Config](#pkgtelemetryconfig)_ | Telemetry configures OpenTelemetry-based observability for the Virtual MCP server
including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint.
Deprecated (Kubernetes operator only): When deploying via the operator, use
VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig
resource instead. This field remains valid for standalone (non-operator) deployments. | | Optional: \{\}
| -| `audit` _[pkg.audit.Config](#pkgauditconfig)_ | Audit configures audit logging for the Virtual MCP server.
When present, audit logs include MCP protocol operations.
See audit.Config for available configuration options. | | Optional: \{\}
| -| `optimizer` _[vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig)_ | Optimizer configures the MCP optimizer for context optimization on large toolsets.
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions. | | Optional: \{\}
| -| `sessionStorage` _[vmcp.config.SessionStorageConfig](#vmcpconfigsessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When provider is "redis", the operator injects Redis connection parameters
(address, db, keyPrefix) here. The Redis password is provided separately via
the THV_SESSION_REDIS_PASSWORD environment variable. | | Optional: \{\}
| -| `rateLimiting` _[ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig)_ | RateLimiting defines rate limiting configuration for the Virtual MCP server.
Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\}
| -| `passthroughHeaders` _string array_ | PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends. Captured at the vMCP incoming edge by
headerforward.CaptureMiddleware and consumed once at session creation
when the per-session backend client's HeaderForwardConfig is built. Names
must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.). | | Optional: \{\}
| #### vmcp.config.ConflictResolutionConfig @@ -522,7 +488,6 @@ MCP output schema (type, description) and runtime value construction (value, def _Appears in:_ - [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) -- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -695,7 +660,6 @@ This matches the proposal's step configuration (lines 180-255). _Appears in:_ - [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) -- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) - [vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) | Field | Description | Default | Validation | @@ -3713,9 +3677,9 @@ _Appears in:_ | `name` _string_ | Name is the workflow name (unique identifier). | | | | `description` _string_ | Description describes what the workflow does. | | | | `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Optional: \{\}
| -| `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| -| `steps` _[vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | -| `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| +| `timeout` _[pkg.vmcpcrd.Duration](#pkgvmcpcrdduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| +| `steps` _[pkg.vmcpcrd.WorkflowStepConfig](#pkgvmcpcrdworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | +| `output` _[pkg.vmcpcrd.OutputConfig](#pkgvmcpcrdoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| #### api.v1beta1.VirtualMCPCompositeToolDefinitionStatus @@ -3822,7 +3786,7 @@ _Appears in:_ | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.
If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the Virtual MCP server runs in, you must specify
the 'vmcp' container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace. | | Required: \{\}
| -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required. | | Type: object
Optional: \{\}
| +| `config` _[pkg.vmcpcrd.Config](#pkgvmcpcrdconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required. | | Optional: \{\}
| | `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1beta1.EmbeddingServerRef](#apiv1beta1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| | `authServerConfig` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| diff --git a/go.mod b/go.mod index d030b77b22..9c81b9ee76 100644 --- a/go.mod +++ b/go.mod @@ -311,7 +311,7 @@ require ( modernc.org/memory v1.11.0 // indirect oras.land/oras-go/v2 v2.6.1 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/randfill v1.0.0 sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) diff --git a/pkg/vmcp/status/k8s_reporter_test.go b/pkg/vmcp/status/k8s_reporter_test.go index 5a0eafe73b..a43e643154 100644 --- a/pkg/vmcp/status/k8s_reporter_test.go +++ b/pkg/vmcp/status/k8s_reporter_test.go @@ -19,8 +19,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) // TestNewK8sReporter_Validation tests parameter validation in NewK8sReporter. @@ -660,7 +660,7 @@ func createTestVirtualMCPServer(t *testing.T, fakeClient client.Client, name, na Generation: 1, }, Spec: mcpv1beta1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: "test-group", }, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go index 452c4580e8..85bce4930c 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -50,12 +50,12 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", // Tool filtering: only allow echo from backend1, nothing from backend2 - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backend1Name, Filter: []string{"echo"}, // Only expose echo tool @@ -176,8 +176,8 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { Expect(vmcpServer.Spec.Config.Aggregation.Tools).To(HaveLen(2)) // Verify backend1 filter allows echo - var backend1Config *vmcpconfig.WorkloadToolConfig - var backend2Config *vmcpconfig.WorkloadToolConfig + var backend1Config *vmcpcrd.WorkloadToolConfig + var backend2Config *vmcpcrd.WorkloadToolConfig for i := range vmcpServer.Spec.Config.Aggregation.Tools { if vmcpServer.Spec.Config.Aggregation.Tools[i].Workload == backend1Name { backend1Config = vmcpServer.Spec.Config.Aggregation.Tools[i] diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go index 9aa103c0f2..530b398382 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -51,18 +51,18 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", // Tool overrides: rename echo to custom_echo_tool with new description // Note: Filter uses the user-facing name (after override), so we filter by // the renamed tool name, not the original name. - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backendName, Filter: []string{renamedToolName}, // Filter by user-facing name (after override) - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ originalToolName: { Name: renamedToolName, Description: newDescription, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go index eef5c2c1ad..de6d35bdcc 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -852,10 +852,10 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, // No TokenCache configured - tokens should be fetched on each request - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go index 61a24c86be..ee5ae8affa 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -163,21 +163,21 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Source: "discovered", }, ServiceType: "NodePort", - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Name: vmcpServerName, Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, - Operational: &vmcpconfig.OperationalConfig{ - FailureHandling: &vmcpconfig.FailureHandlingConfig{ - HealthCheckInterval: vmcpconfig.Duration(cbHealthCheckInterval), - HealthCheckTimeout: vmcpconfig.Duration(cbHealthCheckTimeout), + Operational: &vmcpcrd.OperationalConfig{ + FailureHandling: &vmcpcrd.FailureHandlingConfig{ + HealthCheckInterval: vmcpcrd.Duration(cbHealthCheckInterval), + HealthCheckTimeout: vmcpcrd.Duration(cbHealthCheckTimeout), UnhealthyThreshold: cbUnhealthyThreshold, - CircuitBreaker: &vmcpconfig.CircuitBreakerConfig{ + CircuitBreaker: &vmcpcrd.CircuitBreakerConfig{ Enabled: true, FailureThreshold: cbFailureThreshold, - Timeout: vmcpconfig.Duration(cbTimeout), + Timeout: vmcpcrd.Duration(cbTimeout), }, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go index 3c36ae707a..34cf0a1a85 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go @@ -13,8 +13,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -49,13 +49,13 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, // Define a composite tool with a conditional step that has defaultResults - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "Conditionally echoes input, uses default when skipped", @@ -73,8 +73,8 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func }, "required": []any{"run_step", "message"}, }), - Timeout: vmcpconfig.Duration(30 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(30 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "conditional_step", Type: "tool", @@ -92,8 +92,8 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func }, }, // Output references the conditional step's output.output - Output: &vmcpconfig.OutputConfig{ - Properties: map[string]vmcpconfig.OutputProperty{ + Output: &vmcpcrd.OutputConfig{ + Properties: map[string]vmcpcrd.OutputProperty{ "result": { Type: "string", Description: "Result from conditional step", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go index bf9b5f9767..18c1146583 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -67,11 +67,11 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { // Backend A: Hide ALL tools using ExcludeAll Workload: backendAName, @@ -86,7 +86,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered }, }, // Define a composite tool that uses tools from BOTH hidden backends - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "A composite tool that echoes via both hidden backends", @@ -100,8 +100,8 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered }, "required": []any{"message"}, }), - Timeout: vmcpconfig.Duration(30 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(30 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { // Step 1: Echo through Backend A (ExcludeAll) ID: "echo_backend_a", @@ -270,7 +270,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered Expect(vmcpServer.Spec.Config.Aggregation.Tools).To(HaveLen(2)) // Find and verify Backend A config (ExcludeAll) - var backendAConfig, backendBConfig *vmcpconfig.WorkloadToolConfig + var backendAConfig, backendBConfig *vmcpcrd.WorkloadToolConfig for _, toolConfig := range vmcpServer.Spec.Config.Aggregation.Tools { switch toolConfig.Workload { case backendAName: diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go index e9378b1b30..90936fd5e3 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go @@ -14,8 +14,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -53,14 +53,14 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, // Define a composite tool that echoes to both backends in parallel // Steps without DependsOn can execute concurrently - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "Echoes message to both backends in parallel, then combines results", @@ -74,8 +74,8 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }, "required": []any{"message"}, }), - Timeout: vmcpconfig.Duration(60 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(60 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { // Step 1: Echo to backend1 (no dependencies - runs in parallel) ID: "echo_backend1", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go index cc0258a2b6..d412da55e4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go @@ -14,8 +14,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -50,7 +50,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func Namespace: testNamespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: compositeToolName, Description: "Echoes the input message twice in sequence (referenced)", Parameters: thvjson.NewMap(map[string]any{ @@ -63,7 +63,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }, "required": []any{"message"}, }), - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "first_echo", Type: "tool", @@ -82,7 +82,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func Arguments: thvjson.NewMap(map[string]any{"input": "{{ .steps.first_echo.result }}"}), }, }, - Timeout: vmcpconfig.Duration(30 * time.Second), + Timeout: vmcpcrd.Duration(30 * time.Second), }, }, } @@ -107,13 +107,13 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, // Reference the composite tool definition instead of defining inline - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ { Name: compositeToolDefName, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go index 341baba83e..af49a2b164 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go @@ -14,8 +14,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -50,13 +50,13 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, // Define a composite tool that echoes input, then echoes the result again - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "Echoes the input message twice in sequence", @@ -70,8 +70,8 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func }, "required": []any{"message"}, }), - Timeout: vmcpconfig.Duration(30 * time.Second), - Steps: []vmcpconfig.WorkflowStepConfig{ + Timeout: vmcpcrd.Duration(30 * time.Second), + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "first_echo", Type: "tool", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go index ec10028738..d94634af41 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go @@ -13,8 +13,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -50,7 +50,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, Namespace: testNamespace, }, Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ - CompositeToolConfig: vmcpconfig.CompositeToolConfig{ + CompositeToolConfig: vmcpcrd.CompositeToolConfig{ Name: "parse_json_workflow", Description: "Workflow that parses JSON text responses using fromJson", Parameters: thvjson.NewMap(map[string]any{ @@ -63,7 +63,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }, "required": []any{"query"}, }), - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "search", Type: "tool", @@ -84,7 +84,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }), }, }, - Timeout: vmcpconfig.Duration(30 * time.Second), + Timeout: vmcpcrd.Duration(30 * time.Second), }, }, } @@ -108,12 +108,12 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, - CompositeToolRefs: []vmcpconfig.CompositeToolRef{ + CompositeToolRefs: []vmcpcrd.CompositeToolRef{ { Name: compositeToolDefName, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go index 9a42247838..e204f49484 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcp "github.com/stacklok/toolhive/pkg/vmcp" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -27,7 +27,7 @@ type conflictResolutionTestSetup struct { backend1Name string backend2Name string namespace string - aggregation *vmcpconfig.AggregationConfig + aggregation *vmcpcrd.AggregationConfig timeout time.Duration pollingInterval time.Duration } @@ -54,7 +54,7 @@ func setupConflictResolutionTest(setup conflictResolutionTestSetup) int32 { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: setup.groupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: setup.groupName, Aggregation: setup.aggregation, }, @@ -133,9 +133,9 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { namespace: testNamespace, timeout: timeout, pollingInterval: pollingInterval, - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + ConflictResolutionConfig: &vmcpcrd.ConflictResolutionConfig{ PrefixFormat: "{workload}_", }, }, @@ -276,9 +276,9 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { namespace: testNamespace, timeout: timeout, pollingInterval: pollingInterval, - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPriority, - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + ConflictResolutionConfig: &vmcpcrd.ConflictResolutionConfig{ PriorityOrder: []string{backend1Name, backend2Name}, }, }, @@ -413,18 +413,18 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { namespace: testNamespace, timeout: timeout, pollingInterval: pollingInterval, - aggregation: &vmcpconfig.AggregationConfig{ + aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyManual, - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backend1Name, - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ "echo": {Name: "echo_backend1"}, }, }, { Workload: backend2Name, - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ "echo": {Name: "echo_backend2"}, }, }, @@ -479,8 +479,8 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { Expect(vmcpServer.Spec.Config.Aggregation.Tools).To(HaveLen(2)) // Verify backend1 overrides - var backend1Config *vmcpconfig.WorkloadToolConfig - var backend2Config *vmcpconfig.WorkloadToolConfig + var backend1Config *vmcpcrd.WorkloadToolConfig + var backend2Config *vmcpcrd.WorkloadToolConfig for i := range vmcpServer.Spec.Config.Aggregation.Tools { if vmcpServer.Spec.Config.Aggregation.Tools[i].Workload == backend1Name { backend1Config = vmcpServer.Spec.Config.Aggregation.Tools[i] diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go index 4adaa48515..4f1432ea49 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go @@ -20,7 +20,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -127,10 +127,10 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, // Discovered mode is the default - tools from all backends in the group are automatically discovered - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", // Use prefix strategy to avoid conflicts }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go index 81d677e601..30e92f25a9 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -49,9 +49,9 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", // Global flag to exclude all tools from all backends ExcludeAllTools: true, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go index 9e7f3a6ea2..83dc98a54f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -868,13 +868,13 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Operational: &vmcpconfig.OperationalConfig{ - FailureHandling: &vmcpconfig.FailureHandlingConfig{ + Operational: &vmcpcrd.OperationalConfig{ + FailureHandling: &vmcpcrd.FailureHandlingConfig{ // Short interval so several health checks run within the test timeout. - HealthCheckInterval: vmcpconfig.Duration(healthCheckAuthInterval), - HealthCheckTimeout: vmcpconfig.Duration(2 * time.Second), + HealthCheckInterval: vmcpcrd.Duration(healthCheckAuthInterval), + HealthCheckTimeout: vmcpcrd.Duration(2 * time.Second), UnhealthyThreshold: 3, }, }, @@ -1085,12 +1085,12 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Operational: &vmcpconfig.OperationalConfig{ - FailureHandling: &vmcpconfig.FailureHandlingConfig{ - HealthCheckInterval: vmcpconfig.Duration(healthCheckAuthInterval), - HealthCheckTimeout: vmcpconfig.Duration(2 * time.Second), + Operational: &vmcpcrd.OperationalConfig{ + FailureHandling: &vmcpcrd.FailureHandlingConfig{ + HealthCheckInterval: vmcpcrd.Duration(healthCheckAuthInterval), + HealthCheckTimeout: vmcpcrd.Duration(2 * time.Second), UnhealthyThreshold: 3, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go index 4e763c220e..10115a22e4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -78,21 +78,21 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: embeddingName, }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Optimizer: &vmcpconfig.OptimizerConfig{}, - Aggregation: &vmcpconfig.AggregationConfig{ + Optimizer: &vmcpcrd.OptimizerConfig{}, + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, - Operational: &vmcpconfig.OperationalConfig{ - FailureHandling: &vmcpconfig.FailureHandlingConfig{ - HealthCheckInterval: vmcpconfig.Duration(cbHealthCheckInterval), - HealthCheckTimeout: vmcpconfig.Duration(cbHealthCheckTimeout), + Operational: &vmcpcrd.OperationalConfig{ + FailureHandling: &vmcpcrd.FailureHandlingConfig{ + HealthCheckInterval: vmcpcrd.Duration(cbHealthCheckInterval), + HealthCheckTimeout: vmcpcrd.Duration(cbHealthCheckTimeout), UnhealthyThreshold: cbUnhealthyThreshold, - CircuitBreaker: &vmcpconfig.CircuitBreakerConfig{ + CircuitBreaker: &vmcpcrd.CircuitBreakerConfig{ Enabled: true, FailureThreshold: cbFailureThreshold, - Timeout: vmcpconfig.Duration(cbTimeout), + Timeout: vmcpcrd.Duration(cbTimeout), }, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go index df53ecd95a..4ab2caf59a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -94,12 +94,12 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { }, // Use embeddingService directly instead of EmbeddingServerRef // to avoid depending on the heavyweight TEI image. - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Optimizer: &vmcpconfig.OptimizerConfig{ + Optimizer: &vmcpcrd.OptimizerConfig{ EmbeddingService: embeddingURL, }, - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "Fetches a URL twice in sequence for verification", @@ -113,7 +113,7 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { }, "required": []string{"url"}, }), - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "first_fetch", Type: "tool", @@ -130,12 +130,12 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { }, }, }, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backendName, - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ backendFetchToolName: { Name: vmcpFetchToolName, Description: vmcpFetchToolDescription, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go index 00fdb05460..6e5c23a87b 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go @@ -16,8 +16,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcp "github.com/stacklok/toolhive/pkg/vmcp" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -194,12 +194,12 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: embeddingName, }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Optimizer: &vmcpconfig.OptimizerConfig{}, - Aggregation: &vmcpconfig.AggregationConfig{ + Optimizer: &vmcpcrd.OptimizerConfig{}, + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + ConflictResolutionConfig: &vmcpcrd.ConflictResolutionConfig{ PrefixFormat: "{workload}_", }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go index 50b80a69fd..5ae925b152 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go @@ -15,8 +15,8 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" thvjson "github.com/stacklok/toolhive/pkg/json" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -96,11 +96,11 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { Name: embeddingName, }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Optimizer: &vmcpconfig.OptimizerConfig{}, + Optimizer: &vmcpcrd.OptimizerConfig{}, // Define a composite tool that calls fetch twice - CompositeTools: []vmcpconfig.CompositeToolConfig{ + CompositeTools: []vmcpcrd.CompositeToolConfig{ { Name: compositeToolName, Description: "Fetches a URL twice in sequence for verification", @@ -114,7 +114,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { }, "required": []string{"url"}, }), - Steps: []vmcpconfig.WorkflowStepConfig{ + Steps: []vmcpcrd.WorkflowStepConfig{ { ID: "first_fetch", Type: "tool", @@ -131,12 +131,12 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { }, }, }, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backendName, - Overrides: map[string]*vmcpconfig.ToolOverride{ + Overrides: map[string]*vmcpcrd.ToolOverride{ backendFetchToolName: { Name: vmcpFetchToolName, Description: vmcpFetchToolDescription, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go index 3a4f89f85b..d2a4349a6a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go @@ -21,7 +21,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -116,7 +116,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, RateLimiting: &mcpv1beta1.RateLimitConfig{ PerUser: &mcpv1beta1.RateLimitBucket{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 2932dd242d..0a6d5e7035 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go @@ -25,7 +25,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -80,7 +80,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, }, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, @@ -471,7 +471,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, }, IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go index c5024b8957..56e39e5aff 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/yaml" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -117,7 +118,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Name: "e2e-telemetry-config", ServiceName: "custom-service-name", }, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go index 08ed6cd766..463732a83b 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -72,15 +72,15 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backend1Name, // Reference MCPToolConfig instead of inline Filter - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: toolConfigName, }, }, @@ -287,7 +287,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f Expect(vmcpServer.Spec.Config.Aggregation.Tools).To(HaveLen(2)) // Verify backend1 has ToolConfigRef - var backend1Config *vmcpconfig.WorkloadToolConfig + var backend1Config *vmcpcrd.WorkloadToolConfig for i := range vmcpServer.Spec.Config.Aggregation.Tools { if vmcpServer.Spec.Config.Aggregation.Tools[i].Workload == backend1Name { backend1Config = vmcpServer.Spec.Config.Aggregation.Tools[i] @@ -363,14 +363,14 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", - Tools: []*vmcpconfig.WorkloadToolConfig{ + Tools: []*vmcpcrd.WorkloadToolConfig{ { Workload: backendName, - ToolConfigRef: &vmcpconfig.ToolConfigRef{ + ToolConfigRef: &vmcpcrd.ToolConfigRef{ Name: toolConfigName, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go index e9b9ec33a4..01d3162fd9 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go @@ -17,7 +17,7 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpcrd "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpcrd" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -112,9 +112,9 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { }, Spec: mcpv1beta1.VirtualMCPServerSpec{ GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, - Config: vmcpconfig.Config{ + Config: vmcpcrd.Config{ Group: mcpGroupName, - Aggregation: &vmcpconfig.AggregationConfig{ + Aggregation: &vmcpcrd.AggregationConfig{ ConflictResolution: "prefix", }, },