Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-e2e-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
docker pull ghcr.io/stackloklabs/osv-mcp/server:0.0.7 &
docker pull python:3.9-slim &
docker pull curlimages/curl:8.17.0 &
docker pull ghcr.io/huggingface/text-embeddings-inference:cpu-latest &
wait

# Load all images into kind
Expand All @@ -108,6 +109,7 @@ jobs:
kind load docker-image --name toolhive ghcr.io/stackloklabs/osv-mcp/server:0.0.7
kind load docker-image --name toolhive python:3.9-slim
kind load docker-image --name toolhive curlimages/curl:8.17.0
kind load docker-image --name toolhive ghcr.io/huggingface/text-embeddings-inference:cpu-latest

- name: Deploy operator with VMCP_IMAGE
run: |
Expand Down
9 changes: 5 additions & 4 deletions cmd/thv-operator/api/v1alpha1/embeddingserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,18 @@ const (
// EmbeddingServerSpec defines the desired state of EmbeddingServer
type EmbeddingServerSpec struct {
// Model is the HuggingFace embedding model to use (e.g., "sentence-transformers/all-MiniLM-L6-v2")
// +kubebuilder:validation:Required
Model string `json:"model"`
// +kubebuilder:default="BAAI/bge-small-en-v1.5"
// +optional
Model string `json:"model,omitempty"`

// HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token.
// If provided, the secret value will be provided to the embedding server for authentication with huggingface.
// +optional
HFTokenSecretRef *SecretKeyRef `json:"hfTokenSecretRef,omitempty"`

// Image is the container image for huggingface-embedding-inference
// +kubebuilder:validation:Required
// +kubebuilder:default="ghcr.io/huggingface/text-embeddings-inference:latest"
// +kubebuilder:default="ghcr.io/huggingface/text-embeddings-inference:cpu-latest"
// +optional
Image string `json:"image,omitempty"`

// ImagePullPolicy defines the pull policy for the container image
Expand Down
63 changes: 63 additions & 0 deletions cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ type VirtualMCPServerSpec struct {
// The telemetry and audit config from here are also supported, but not required.
// +optional
Config config.Config `json:"config,omitempty"`

// 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
EmbeddingServerRef *EmbeddingServerRef `json:"embeddingServerRef,omitempty"`
}

// EmbeddingServerRef references an existing EmbeddingServer resource by name.
// This follows the same pattern as ExternalAuthConfigRef and ToolConfigRef.
type EmbeddingServerRef struct {
// Name is the name of the EmbeddingServer resource
// +kubebuilder:validation:Required
Name string `json:"name"`
}

// IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server
Expand Down Expand Up @@ -199,6 +214,9 @@ const (

// ConditionTypeVirtualMCPServerBackendsDiscovered indicates whether backends have been discovered
ConditionTypeVirtualMCPServerBackendsDiscovered = "BackendsDiscovered"

// ConditionTypeEmbeddingServerReady indicates whether the EmbeddingServer is ready
ConditionTypeEmbeddingServerReady = "EmbeddingServerReady"
)

// Condition reasons for VirtualMCPServer
Expand Down Expand Up @@ -247,6 +265,15 @@ const (

// ConditionReasonVirtualMCPServerDeploymentNotReady indicates the deployment is not ready
ConditionReasonVirtualMCPServerDeploymentNotReady = "DeploymentNotReady"

// ConditionReasonEmbeddingServerReady indicates the EmbeddingServer is ready
ConditionReasonEmbeddingServerReady = "EmbeddingServerReady"

// ConditionReasonEmbeddingServerNotFound indicates the referenced EmbeddingServer was not found
ConditionReasonEmbeddingServerNotFound = "EmbeddingServerNotFound"

// ConditionReasonEmbeddingServerNotReady indicates the referenced EmbeddingServer is not ready
ConditionReasonEmbeddingServerNotReady = "EmbeddingServerNotReady"
)

// Backend authentication types
Expand Down Expand Up @@ -358,6 +385,42 @@ func (r *VirtualMCPServer) Validate() error {
}
}

// Validate EmbeddingServer / EmbeddingServerRef
return r.validateEmbeddingServer()
}

// validateEmbeddingServer validates EmbeddingServerRef and Optimizer configuration.
// Rules:
// - embeddingServerRef.name must be non-empty when ref is provided
// - optimizer requires either embeddingServerRef or a manually set embeddingService
// - if embeddingServerRef is set without optimizer, auto-populate optimizer with defaults
//
// The controller handles the remaining cases at runtime (event emission, URL population).
func (r *VirtualMCPServer) validateEmbeddingServer() error {
// Validate ref name is non-empty
if r.Spec.EmbeddingServerRef != nil && r.Spec.EmbeddingServerRef.Name == "" {
return fmt.Errorf("spec.embeddingServerRef.name is required")
}

hasOptimizer := r.Spec.Config.Optimizer != nil
hasRef := r.Spec.EmbeddingServerRef != nil
hasManualService := hasOptimizer && r.Spec.Config.Optimizer.EmbeddingService != ""

// Optimizer configured without any embedding source is an error.
// The user must either set embeddingServerRef or manually set optimizer.embeddingService.
if hasOptimizer && !hasRef && !hasManualService {
return fmt.Errorf(
"spec.config.optimizer requires an embedding service: " +
"set spec.embeddingServerRef (recommended) or spec.config.optimizer.embeddingService")
}

// EmbeddingServerRef is set but optimizer is not configured: auto-populate
// 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{}
}

return nil
}

Expand Down
98 changes: 98 additions & 0 deletions cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

vmcp "github.com/stacklok/toolhive/pkg/vmcp"
Expand Down Expand Up @@ -381,3 +382,100 @@ func TestCompositeToolStepDependencies(t *testing.T) {
})
}
}

func TestValidateEmbeddingServer(t *testing.T) {
t.Parallel()

tests := []struct {
name string
server *VirtualMCPServer
expectError bool
errContains string
expectOptimizer bool
}{
{
name: "ref_without_optimizer_auto_populates_defaults",
server: &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
Config: config.Config{Group: "test-group"},
EmbeddingServerRef: &EmbeddingServerRef{
Name: "my-embedding",
},
},
},
expectOptimizer: true,
},
{
name: "ref_with_optimizer_keeps_existing",
server: &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
Config: config.Config{
Group: "test-group",
Optimizer: &config.OptimizerConfig{},
},
EmbeddingServerRef: &EmbeddingServerRef{
Name: "my-embedding",
},
},
},
expectOptimizer: true,
},
{
name: "optimizer_without_ref_or_service_errors",
server: &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
Config: config.Config{
Group: "test-group",
Optimizer: &config.OptimizerConfig{},
},
},
},
expectError: true,
errContains: "spec.config.optimizer requires an embedding service",
},
{
name: "empty_ref_name_errors",
server: &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
Config: config.Config{Group: "test-group"},
EmbeddingServerRef: &EmbeddingServerRef{Name: ""},
},
},
expectError: true,
errContains: "spec.embeddingServerRef.name is required",
},
{
name: "no_ref_no_optimizer_succeeds",
server: &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
Config: config.Config{Group: "test-group"},
},
},
expectOptimizer: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := tt.server.Validate()
if tt.expectError {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
require.NoError(t, err)

if tt.expectOptimizer {
assert.NotNil(t, tt.server.Spec.Config.Optimizer,
"Optimizer should be populated after validation")
} else {
assert.Nil(t, tt.server.Spec.Config.Optimizer,
"Optimizer should remain nil")
}
})
}
}
20 changes: 20 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading