Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ cmd/thv-operator/.task/checksum/crdref-gen
# Test coverage
coverage*

crd-helm-wrapper
crd-helm-wrapper
cmd/vmcp/__debug_bin*
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ linters:
- third_party$
- builtin$
- examples$
- scripts$
formatters:
enable:
- gci
Expand All @@ -155,3 +156,4 @@ formatters:
- third_party$
- builtin$
- examples$
- scripts$
11 changes: 8 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ tasks:
- task: test-e2e-windows
platforms: [windows]

test-optimizer:
desc: Run optimizer integration tests with sqlite-vec
cmds:
- ./scripts/test-optimizer-with-sqlite-vec.sh

test-all:
desc: Run all tests (unit and e2e)
deps: [test, test-e2e]
Expand Down Expand Up @@ -200,12 +205,12 @@ tasks:
cmds:
- cmd: mkdir -p bin
platforms: [linux, darwin]
- cmd: go build -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -o bin/vmcp ./cmd/vmcp
- cmd: go build -tags="fts5" -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -o bin/vmcp ./cmd/vmcp
platforms: [linux, darwin]
- cmd: cmd.exe /c mkdir bin
platforms: [windows]
ignore_error: true
- cmd: go build -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -o bin/vmcp.exe ./cmd/vmcp
- cmd: go build -tags="fts5" -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -o bin/vmcp.exe ./cmd/vmcp
platforms: [windows]

install-vmcp:
Expand All @@ -217,7 +222,7 @@ tasks:
sh: git rev-parse --short HEAD || echo "unknown"
BUILD_DATE: '{{dateInZone "2006-01-02T15:04:05Z" (now) "UTC"}}'
cmds:
- go install -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -v ./cmd/vmcp
- go install -tags="fts5" -ldflags "-s -w -X github.com/stacklok/toolhive/pkg/versions.Version={{.VERSION}} -X github.com/stacklok/toolhive/pkg/versions.Commit={{.COMMIT}} -X github.com/stacklok/toolhive/pkg/versions.BuildDate={{.BUILD_DATE}}" -v ./cmd/vmcp

all:
desc: Run linting, tests, and build
Expand Down
2 changes: 1 addition & 1 deletion cmd/thv-operator/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ tasks:
ignore_error: true # Windows has no mkdir -p, so just ignore error if it exists
- go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.3
- $(go env GOPATH)/bin/controller-gen rbac:roleName=toolhive-operator-manager-role paths="{{.CONTROLLER_GEN_PATHS}}" output:rbac:artifacts:config={{.PROJECT_ROOT}}/deploy/charts/operator/templates/clusterrole
- $(go env GOPATH)/bin/controller-gen crd webhook paths="{{.CONTROLLER_GEN_PATHS}}" output:crd:artifacts:config={{.PROJECT_ROOT}}/deploy/charts/operator-crds/files/crds
- $(go env GOPATH)/bin/controller-gen crd:allowDangerousTypes=true webhook paths="{{.CONTROLLER_GEN_PATHS}}" output:crd:artifacts:config={{.PROJECT_ROOT}}/deploy/charts/operator-crds/files/crds
# Wrap CRDs with Helm templates for conditional installation
- go run {{.PROJECT_ROOT}}/deploy/charts/operator-crds/crd-helm-wrapper/main.go -source {{.PROJECT_ROOT}}/deploy/charts/operator-crds/files/crds -target {{.PROJECT_ROOT}}/deploy/charts/operator-crds/templates
# - "{{.PROJECT_ROOT}}/deploy/charts/operator-crds/scripts/wrap-crds.sh"
Expand Down
3 changes: 3 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,9 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *
}

// ensureRBACResources ensures that the RBAC resources are in place for the remote proxy
// TODO: This uses EnsureRBACResource which only creates RBAC but never updates them.
// Consider adopting the MCPRegistry pattern (pkg/registryapi/rbac.go) which uses
// CreateOrUpdate + RetryOnConflict to automatically update RBAC rules during operator upgrades.
func (r *MCPRemoteProxyReconciler) ensureRBACResources(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error {
proxyRunnerNameForRBAC := proxyRunnerServiceAccountNameForRemoteProxy(proxy.Name)

Expand Down
107 changes: 86 additions & 21 deletions cmd/thv-operator/controllers/virtualmcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,14 +496,31 @@ func (r *VirtualMCPServerReconciler) ensureAllResources(
return nil
}

// ensureRBACResources ensures that the RBAC resources are in place for the VirtualMCPServer
// ensureRBACResources ensures RBAC resources for VirtualMCPServer in dynamic mode.
// In static mode, RBAC creation is skipped. When switching dynamic→static, existing RBAC
// resources are NOT deleted - they persist until VirtualMCPServer deletion via owner references.
// This follows standard Kubernetes garbage collection patterns.
//
// TODO: This uses EnsureRBACResource which only creates RBAC but never updates them.
// Consider adopting the MCPRegistry pattern (pkg/registryapi/rbac.go) which uses
// CreateOrUpdate + RetryOnConflict to automatically update RBAC rules during operator upgrades.
func (r *VirtualMCPServerReconciler) ensureRBACResources(
ctx context.Context,
vmcp *mcpv1alpha1.VirtualMCPServer,
) error {
// Determine the outgoing auth source mode
source := outgoingAuthSource(vmcp)

// Static mode (inline): Skip RBAC creation/deletion
// Existing resources from dynamic mode persist until VirtualMCPServer deletion
if source == OutgoingAuthSourceInline {
return nil
}

// Dynamic mode (discovered): Ensure RBAC resources exist
serviceAccountName := vmcpServiceAccountName(vmcp.Name)

// Ensure Role with minimal permissions
// Ensure Role with permissions to discover backends and update status
if err := ctrlutil.EnsureRBACResource(ctx, r.Client, r.Scheme, vmcp, "Role", func() client.Object {
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -848,13 +865,9 @@ func (r *VirtualMCPServerReconciler) containerNeedsUpdate(
}

// Check if service account has changed
expectedServiceAccountName := vmcpServiceAccountName(vmcp.Name)
expectedServiceAccountName := r.serviceAccountNameForVmcp(vmcp)
currentServiceAccountName := deployment.Spec.Template.Spec.ServiceAccountName
if currentServiceAccountName != "" && currentServiceAccountName != expectedServiceAccountName {
return true
}

return false
return currentServiceAccountName != expectedServiceAccountName
}

// deploymentMetadataNeedsUpdate checks if deployment-level metadata has changed
Expand Down Expand Up @@ -1249,6 +1262,31 @@ func vmcpServiceAccountName(vmcpName string) string {
return fmt.Sprintf("%s-vmcp", vmcpName)
}

// outgoingAuthSource returns the outgoing auth source mode with default fallback.
// Returns OutgoingAuthSourceDiscovered if not specified.
func outgoingAuthSource(vmcp *mcpv1alpha1.VirtualMCPServer) string {
if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Source != "" {
return vmcp.Spec.OutgoingAuth.Source
}
return OutgoingAuthSourceDiscovered
}

// serviceAccountNameForVmcp returns the service account name for a VirtualMCPServer
// based on its outgoing auth source mode.
// - Dynamic mode (discovered): Returns the dedicated service account name
// - Static mode (inline): Returns empty string (uses default service account)
func (*VirtualMCPServerReconciler) serviceAccountNameForVmcp(vmcp *mcpv1alpha1.VirtualMCPServer) string {
source := outgoingAuthSource(vmcp)

// Static mode: Use default service account (no RBAC resources)
if source == OutgoingAuthSourceInline {
return ""
}

// Dynamic mode: Use dedicated service account with K8s API permissions
return vmcpServiceAccountName(vmcp.Name)
}

// vmcpServiceName generates the service name for a VirtualMCPServer
// Uses "vmcp-" prefix to distinguish from MCPServer's "mcp-{name}-proxy" pattern.
// This allows VirtualMCPServer and MCPServer to coexist with the same base name.
Expand Down Expand Up @@ -1472,10 +1510,7 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig(
typedWorkloads []workloads.TypedWorkload,
) (*vmcpconfig.OutgoingAuthConfig, error) {
// Determine source - default to "discovered" if not specified
source := OutgoingAuthSourceDiscovered
if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Source != "" {
source = vmcp.Spec.OutgoingAuth.Source
}
source := outgoingAuthSource(vmcp)

outgoing := &vmcpconfig.OutgoingAuthConfig{
Source: source,
Expand All @@ -1491,10 +1526,11 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig(
outgoing.Default = defaultStrategy
}

// Discover ExternalAuthConfig from MCPServers if source is "discovered"
if source == OutgoingAuthSourceDiscovered {
r.discoverExternalAuthConfigs(ctx, vmcp, typedWorkloads, outgoing)
}
// Discover ExternalAuthConfig from MCPServers to populate backend auth configs.
// This function is called from ensureVmcpConfigConfigMap only for inline/static mode,
// where we need full backend details in the ConfigMap. For discovered/dynamic mode,
// this function is not called, keeping the ConfigMap minimal.
r.discoverExternalAuthConfigs(ctx, vmcp, typedWorkloads, outgoing)

// Apply inline overrides (works for all source modes)
if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Backends != nil {
Expand All @@ -1510,9 +1546,42 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig(
return outgoing, nil
}

// convertBackendsToStaticBackends converts Backend objects to StaticBackendConfig for ConfigMap embedding.
// Preserves metadata and uses transport types from workload Specs.
// Logs warnings when backends are skipped due to missing URL or transport information.
func convertBackendsToStaticBackends(
ctx context.Context,
backends []vmcptypes.Backend,
transportMap map[string]string,
) []vmcpconfig.StaticBackendConfig {
logger := log.FromContext(ctx)
static := make([]vmcpconfig.StaticBackendConfig, 0, len(backends))
for _, backend := range backends {
if backend.BaseURL == "" {
logger.V(1).Info("Skipping backend without URL in static mode",
"backend", backend.Name)
continue
}

transport := transportMap[backend.Name]
if transport == "" {
logger.V(1).Info("Skipping backend without transport information in static mode",
"backend", backend.Name)
continue
}

static = append(static, vmcpconfig.StaticBackendConfig{
Name: backend.Name,
URL: backend.BaseURL,
Transport: transport,
Metadata: backend.Metadata,
})
}
return static
}

// discoverBackends discovers all MCPServers in the referenced MCPGroup and returns
// a list of DiscoveredBackend objects with their current status.
// This reuses the existing workload discovery code from pkg/vmcp/workloads.
//
//nolint:gocyclo
func (r *VirtualMCPServerReconciler) discoverBackends(
Expand All @@ -1521,13 +1590,9 @@ func (r *VirtualMCPServerReconciler) discoverBackends(
) ([]mcpv1alpha1.DiscoveredBackend, error) {
ctxLogger := log.FromContext(ctx)

// Create groups manager using the controller's client and VirtualMCPServer's namespace
groupsManager := groups.NewCRDManager(r.Client, vmcp.Namespace)

// Create K8S workload discoverer for the VirtualMCPServer's namespace
workloadDiscoverer := workloads.NewK8SDiscovererWithClient(r.Client, vmcp.Namespace)

// Get all workloads in the group
typedWorkloads, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcp.Spec.Config.Group)
if err != nil {
return nil, fmt.Errorf("failed to list workloads in group: %w", err)
Expand Down
13 changes: 10 additions & 3 deletions cmd/thv-operator/controllers/virtualmcpserver_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ const (
vmcpReadinessFailures = int32(3) // consecutive failures before removing from service
)

// RBAC rules for VirtualMCPServer service account
// RBAC rules for VirtualMCPServer service account in dynamic mode
// These rules allow vMCP to discover backends and configurations at runtime
var vmcpRBACRules = []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Expand All @@ -58,9 +59,14 @@ var vmcpRBACRules = []rbacv1.PolicyRule{
},
{
APIGroups: []string{"toolhive.stacklok.dev"},
Resources: []string{"mcpgroups", "mcpservers", "mcpremoteproxies", "mcpexternalauthconfigs"},
Resources: []string{"mcpgroups", "mcpservers", "mcpremoteproxies", "mcpexternalauthconfigs", "mcptoolconfigs"},
Verbs: []string{"get", "list", "watch"},
},
{
APIGroups: []string{"toolhive.stacklok.dev"},
Resources: []string{"virtualmcpservers/status"},
Verbs: []string{"update", "patch"},
},
}

// deploymentForVirtualMCPServer returns a VirtualMCPServer Deployment object
Expand All @@ -80,6 +86,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer(
deploymentLabels, deploymentAnnotations := r.buildDeploymentMetadataForVmcp(ls, vmcp)
deploymentTemplateLabels, deploymentTemplateAnnotations := r.buildPodTemplateMetadata(ls, vmcp, vmcpConfigChecksum)
podSecurityContext, containerSecurityContext := r.buildSecurityContextsForVmcp(ctx, vmcp)
serviceAccountName := r.serviceAccountNameForVmcp(vmcp)

dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Expand All @@ -99,7 +106,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer(
Annotations: deploymentTemplateAnnotations,
},
Spec: corev1.PodSpec{
ServiceAccountName: vmcpServiceAccountName(vmcp.Name),
ServiceAccountName: serviceAccountName,
Containers: []corev1.Container{{
Image: getVmcpImage(),
ImagePullPolicy: corev1.PullIfNotPresent,
Expand Down
Loading
Loading