Skip to content
Open
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
4 changes: 2 additions & 2 deletions cmd/thv-operator/api/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,9 @@ type MCPToolConfigList struct {
//+kubebuilder:subresource:status
//+kubebuilder:resource:shortName=vmcpctd;compositetool,categories=toolhive
//+kubebuilder:printcolumn:name="Workflow",type="string",JSONPath=".spec.name",description="Workflow name"
//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".spec.steps[*]",description="Number of steps"
//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".status.stepCount",description="Number of steps"
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.validationStatus",description="Validation status"
//+kubebuilder:printcolumn:name="Refs",type="integer",JSONPath=".status.referencingVirtualServers[*]",description="Refs"
//+kubebuilder:printcolumn:name="Refs",type="integer",JSONPath=".status.refCount",description="Referencing servers"
//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ type VirtualMCPCompositeToolDefinitionSpec struct {

// VirtualMCPCompositeToolDefinitionStatus defines the observed state of VirtualMCPCompositeToolDefinition
type VirtualMCPCompositeToolDefinitionStatus struct {
// StepCount is the number of steps in the composite tool workflow.
// +optional
StepCount int32 `json:"stepCount,omitempty"`

// RefCount is the number of VirtualMCPServers that reference this workflow.
// +optional
RefCount int32 `json:"refCount,omitempty"`

// ValidationStatus indicates the validation state of the workflow
// - Valid: Workflow structure is valid
// - Invalid: Workflow has validation errors
Expand Down Expand Up @@ -102,9 +110,9 @@ const (
//+kubebuilder:subresource:status
//+kubebuilder:resource:shortName=vmcpctd;compositetool,categories=toolhive
//+kubebuilder:printcolumn:name="Workflow",type="string",JSONPath=".spec.name",description="Workflow name"
//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".spec.steps[*]",description="Number of steps"
//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".status.stepCount",description="Number of steps"
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.validationStatus",description="Validation status"
//+kubebuilder:printcolumn:name="Refs",type="integer",JSONPath=".status.referencingVirtualServers[*]",description="Refs"
//+kubebuilder:printcolumn:name="Refs",type="integer",JSONPath=".status.refCount",description="Referencing servers"
//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"

Expand Down
9 changes: 8 additions & 1 deletion cmd/thv-operator/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ func setupRegistryController(mgr ctrl.Manager, imagePullSecretsDefaults imagepul
}

// setupAggregationControllers sets up Virtual MCP-related controllers and webhooks
// (MCPGroup, VirtualMCPServer, and their webhooks). Must run after
// (MCPGroup, VirtualMCPServer, VirtualMCPCompositeToolDefinition, and their webhooks). Must run after
// setupServerControllers, which creates the MCPServer.Spec.GroupRef field index
// these controllers depend on.
// imagePullSecretsDefaults are merged with vmcp.Spec.ImagePullSecrets when the
Expand All @@ -340,6 +340,13 @@ func setupAggregationControllers(mgr ctrl.Manager, imagePullSecretsDefaults imag
return fmt.Errorf("unable to create controller VirtualMCPServer: %w", err)
}

// Set up VirtualMCPCompositeToolDefinition controller
if err := (&controllers.VirtualMCPCompositeToolDefinitionReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller VirtualMCPCompositeToolDefinition: %w", err)
}

return nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package controllers

import (
"context"
"sort"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
)

// VirtualMCPCompositeToolDefinitionReconciler maintains display-oriented status
// for a VirtualMCPCompositeToolDefinition.
type VirtualMCPCompositeToolDefinitionReconciler struct {
client.Client
}

// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpcompositetooldefinitions,verbs=get;list;watch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpcompositetooldefinitions/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpservers,verbs=get;list;watch

// Reconcile updates the scalar counts used by kubectl printer columns and
// maintains the existing referencingVirtualServers status list.
func (r *VirtualMCPCompositeToolDefinitionReconciler) Reconcile(
ctx context.Context,
req ctrl.Request,
) (ctrl.Result, error) {
compositeToolDefinition := &mcpv1beta1.VirtualMCPCompositeToolDefinition{}
if err := r.Get(ctx, req.NamespacedName, compositeToolDefinition); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

referencingVirtualServers, err := r.findReferencingVirtualServers(ctx, compositeToolDefinition)
if err != nil {
return ctrl.Result{}, err
}

stepCount := compositeToolDefinitionItemCount(len(compositeToolDefinition.Spec.Steps))
refCount := compositeToolDefinitionItemCount(len(referencingVirtualServers))

if err := ctrlutil.MutateAndPatchStatus(ctx, r.Client, compositeToolDefinition,
func(definition *mcpv1beta1.VirtualMCPCompositeToolDefinition) {
definition.Status.StepCount = stepCount
definition.Status.RefCount = refCount
definition.Status.ReferencingVirtualServers = referencingVirtualServers
}); err != nil {
return ctrl.Result{}, err
}

return ctrl.Result{}, nil
}

func (r *VirtualMCPCompositeToolDefinitionReconciler) findReferencingVirtualServers(
ctx context.Context,
compositeToolDefinition *mcpv1beta1.VirtualMCPCompositeToolDefinition,
) ([]string, error) {
virtualMCPServers := &mcpv1beta1.VirtualMCPServerList{}
if err := r.List(ctx, virtualMCPServers, client.InNamespace(compositeToolDefinition.Namespace)); err != nil {
return nil, err
}

referencingVirtualServers := make([]string, 0)
for _, virtualMCPServer := range virtualMCPServers.Items {
for _, ref := range virtualMCPServer.Spec.Config.CompositeToolRefs {
if ref.Name == compositeToolDefinition.Name {
referencingVirtualServers = append(referencingVirtualServers, virtualMCPServer.Name)
break
}
}
}
sort.Strings(referencingVirtualServers)
return referencingVirtualServers, nil
}

func compositeToolDefinitionItemCount(length int) int32 {
return int32(length) //nolint:gosec // Kubernetes object size limits keep CRD list lengths within int32.
}

// mapVirtualMCPServerToCompositeToolDefinitions enqueues both definitions a server
// currently references and definitions that still contain a stale reference to it.
func (r *VirtualMCPCompositeToolDefinitionReconciler) mapVirtualMCPServerToCompositeToolDefinitions(
ctx context.Context,
obj client.Object,
) []reconcile.Request {
virtualMCPServer, ok := obj.(*mcpv1beta1.VirtualMCPServer)
if !ok {
return nil
}

requests := make([]reconcile.Request, 0, len(virtualMCPServer.Spec.Config.CompositeToolRefs))
seen := make(map[types.NamespacedName]struct{})
for _, ref := range virtualMCPServer.Spec.Config.CompositeToolRefs {
name := types.NamespacedName{Name: ref.Name, Namespace: virtualMCPServer.Namespace}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
requests = append(requests, reconcile.Request{NamespacedName: name})
}

definitions := &mcpv1beta1.VirtualMCPCompositeToolDefinitionList{}
if err := r.List(ctx, definitions, client.InNamespace(virtualMCPServer.Namespace)); err != nil {
log.FromContext(ctx).Error(err, "Failed to list VirtualMCPCompositeToolDefinitions for VirtualMCPServer watch")
return requests
}
for _, definition := range definitions.Items {
name := types.NamespacedName{Name: definition.Name, Namespace: definition.Namespace}
if _, exists := seen[name]; exists {
continue
}
for _, referencingVirtualServer := range definition.Status.ReferencingVirtualServers {
if referencingVirtualServer == virtualMCPServer.Name {
requests = append(requests, reconcile.Request{NamespacedName: name})
break
}
}
}
return requests
}

// SetupWithManager configures reconciliation of definitions and the virtual
// servers whose references determine the Refs column.
func (r *VirtualMCPCompositeToolDefinitionReconciler) SetupWithManager(mgr ctrl.Manager) error {
virtualMCPServerHandler := handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToCompositeToolDefinitions)

return ctrl.NewControllerManagedBy(mgr).
For(&mcpv1beta1.VirtualMCPCompositeToolDefinition{}).
Watches(&mcpv1beta1.VirtualMCPServer{}, virtualMCPServerHandler).
Complete(r)
}
Loading
Loading