Skip to content

Commit 059629c

Browse files
ChrisJBurnsclaude
andcommitted
Wire MCPOIDCConfig into VirtualMCPServer controller
VirtualMCPServer can now reference a shared MCPOIDCConfig resource via incomingAuth.oidcConfigRef, matching the pattern already established for MCPServer. The legacy inline oidcConfig field is deprecated and will be removed in v1beta1. Key changes: - Add oidcConfigRef field to IncomingAuthConfig with CEL mutual-exclusivity validation against the deprecated oidcConfig field - Converter resolves OIDC config from MCPOIDCConfig references using ResolveFromConfigRef, including client secret and CA bundle handling - VirtualMCPServer controller validates the referenced MCPOIDCConfig, tracks config hash changes, and watches for MCPOIDCConfig updates - MCPOIDCConfig controller now tracks VirtualMCPServer references in ReferencingWorkloads and blocks deletion while referenced - Deployment builder propagates errors from MCPOIDCConfig fetch failures rather than silently producing misconfigured deployments Closes #4253 Closes #4248 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 610075f commit 059629c

13 files changed

Lines changed: 1267 additions & 49 deletions

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,21 +109,30 @@ type EmbeddingServerRef struct {
109109

110110
// IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server
111111
//
112-
// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? has(self.oidcConfig) : true",message="spec.incomingAuth.oidcConfig is required when type is oidc"
112+
// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? (has(self.oidcConfig) || has(self.oidcConfigRef)) : true",message="spec.incomingAuth.oidcConfig or oidcConfigRef is required when type is oidc"
113+
// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig"
113114
//
114-
//nolint:lll // CEL validation rule exceeds line length limit
115+
//nolint:lll // CEL validation rules exceed line length limit
115116
type IncomingAuthConfig struct {
116117
// Type defines the authentication type: anonymous or oidc
117118
// When no authentication is required, explicitly set this to "anonymous"
118119
// +kubebuilder:validation:Enum=anonymous;oidc
119120
// +kubebuilder:validation:Required
120121
Type string `json:"type"`
121122

122-
// OIDCConfig defines OIDC authentication configuration
123-
// Reuses MCPServer OIDC patterns
123+
// OIDCConfig defines OIDC authentication configuration.
124+
// Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.
125+
// This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive.
124126
// +optional
125127
OIDCConfig *OIDCConfigRef `json:"oidcConfig,omitempty"`
126128

129+
// OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
130+
// The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.
131+
// Per-server overrides (audience, scopes) are specified here; shared provider config
132+
// lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig.
133+
// +optional
134+
OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"`
135+
127136
// AuthzConfig defines authorization policy configuration
128137
// Reuses MCPServer authz patterns
129138
// +optional
@@ -210,6 +219,11 @@ type VirtualMCPServerStatus struct {
210219
// (excludes unavailable, degraded, and unknown backends)
211220
// +optional
212221
BackendCount int `json:"backendCount,omitempty"`
222+
223+
// OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection.
224+
// Only populated when IncomingAuth.OIDCConfigRef is set.
225+
// +optional
226+
OIDCConfigHash string `json:"oidcConfigHash,omitempty"`
213227
}
214228

215229
// VirtualMCPServerPhase represents the lifecycle phase of a VirtualMCPServer

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/controllers/mcpoidcconfig_controller.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type MCPOIDCConfigReconciler struct {
4646
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs,verbs=get;list;watch;create;update;patch;delete
4747
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs/status,verbs=get;update;patch
4848
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs/finalizers,verbs=update
49+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpservers,verbs=get;list;watch
4950

5051
// Reconcile is part of the main kubernetes reconciliation loop which aims to
5152
// move the current state of the cluster closer to the desired state.
@@ -199,7 +200,7 @@ func (r *MCPOIDCConfigReconciler) handleDeletion(
199200
return ctrl.Result{}, nil
200201
}
201202

202-
// findReferencingWorkloads returns the workload resources (MCPServer)
203+
// findReferencingWorkloads returns the workload resources (MCPServer and VirtualMCPServer)
203204
// that reference this MCPOIDCConfig via their OIDCConfigRef field.
204205
func (r *MCPOIDCConfigReconciler) findReferencingWorkloads(
205206
ctx context.Context,
@@ -216,11 +217,25 @@ func (r *MCPOIDCConfigReconciler) findReferencingWorkloads(
216217
refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: server.Name})
217218
}
218219
}
220+
221+
// Check VirtualMCPServers
222+
vmcpList := &mcpv1alpha1.VirtualMCPServerList{}
223+
if err := r.List(ctx, vmcpList, client.InNamespace(oidcConfig.Namespace)); err != nil {
224+
return nil, fmt.Errorf("failed to list VirtualMCPServers: %w", err)
225+
}
226+
for _, vmcp := range vmcpList.Items {
227+
if vmcp.Spec.IncomingAuth != nil &&
228+
vmcp.Spec.IncomingAuth.OIDCConfigRef != nil &&
229+
vmcp.Spec.IncomingAuth.OIDCConfigRef.Name == oidcConfig.Name {
230+
refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcp.Name})
231+
}
232+
}
233+
219234
return refs, nil
220235
}
221236

222237
// SetupWithManager sets up the controller with the Manager.
223-
// Watches MCPServer changes to maintain accurate ReferencingWorkloads status.
238+
// Watches MCPServer and VirtualMCPServer changes to maintain accurate ReferencingWorkloads status.
224239
func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
225240
// Watch MCPServer changes to update ReferencingWorkloads on referenced MCPOIDCConfigs.
226241
// This handler enqueues both the currently-referenced MCPOIDCConfig AND any
@@ -274,5 +289,56 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
274289
return ctrl.NewControllerManagedBy(mgr).
275290
For(&mcpv1alpha1.MCPOIDCConfig{}).
276291
Watches(&mcpv1alpha1.MCPServer{}, mcpServerHandler).
292+
Watches(
293+
&mcpv1alpha1.VirtualMCPServer{},
294+
handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToOIDCConfig),
295+
).
277296
Complete(r)
278297
}
298+
299+
// mapVirtualMCPServerToOIDCConfig maps VirtualMCPServer changes to MCPOIDCConfig reconciliation requests.
300+
// Enqueues both the currently-referenced config and any config that still lists this
301+
// VirtualMCPServer in ReferencingWorkloads (handles ref-removal / deletion).
302+
func (r *MCPOIDCConfigReconciler) mapVirtualMCPServerToOIDCConfig(
303+
ctx context.Context, obj client.Object,
304+
) []reconcile.Request {
305+
vmcp, ok := obj.(*mcpv1alpha1.VirtualMCPServer)
306+
if !ok {
307+
return nil
308+
}
309+
310+
seen := make(map[types.NamespacedName]struct{})
311+
var requests []reconcile.Request
312+
313+
// Enqueue the currently-referenced MCPOIDCConfig (if any)
314+
if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef != nil {
315+
nn := types.NamespacedName{
316+
Name: vmcp.Spec.IncomingAuth.OIDCConfigRef.Name,
317+
Namespace: vmcp.Namespace,
318+
}
319+
seen[nn] = struct{}{}
320+
requests = append(requests, reconcile.Request{NamespacedName: nn})
321+
}
322+
323+
// Also enqueue any MCPOIDCConfig that still lists this VirtualMCPServer in
324+
// ReferencingWorkloads — handles ref-removal and deletion cases.
325+
oidcConfigList := &mcpv1alpha1.MCPOIDCConfigList{}
326+
if err := r.List(ctx, oidcConfigList, client.InNamespace(vmcp.Namespace)); err != nil {
327+
log.FromContext(ctx).Error(err, "Failed to list MCPOIDCConfigs for VirtualMCPServer watch")
328+
return requests
329+
}
330+
for _, cfg := range oidcConfigList.Items {
331+
nn := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
332+
if _, already := seen[nn]; already {
333+
continue
334+
}
335+
for _, ref := range cfg.Status.ReferencingWorkloads {
336+
if ref.Kind == "VirtualMCPServer" && ref.Name == vmcp.Name {
337+
requests = append(requests, reconcile.Request{NamespacedName: nn})
338+
break
339+
}
340+
}
341+
}
342+
343+
return requests
344+
}

cmd/thv-operator/controllers/virtualmcpserver_controller.go

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
corev1 "k8s.io/api/core/v1"
2121
rbacv1 "k8s.io/api/rbac/v1"
2222
"k8s.io/apimachinery/pkg/api/errors"
23+
"k8s.io/apimachinery/pkg/api/meta"
2324
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2425
"k8s.io/apimachinery/pkg/runtime"
2526
"k8s.io/apimachinery/pkg/types"
@@ -118,6 +119,8 @@ type VirtualMCPServerReconciler struct {
118119
// +kubebuilder:rbac:groups="",resources=secrets,verbs=create;get;list;watch
119120
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=create;delete;get;list;patch;update;watch
120121
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=create;delete;get;list;patch;update;watch
122+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs,verbs=get;list;watch
123+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs/status,verbs=get;update;patch
121124
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=embeddingservers,verbs=get;list;watch
122125
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=embeddingservers/status,verbs=get
123126

@@ -151,6 +154,15 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req
151154
return ctrl.Result{}, nil
152155
}
153156

157+
// Validate MCPOIDCConfigRef if referenced (before resource creation).
158+
// handleOIDCConfig is a no-op when OIDCConfigRef is nil.
159+
if err := r.handleOIDCConfig(ctx, vmcp, statusManager); err != nil {
160+
if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil {
161+
ctxLogger.Error(applyErr, "Failed to apply status updates after MCPOIDCConfig validation error")
162+
}
163+
return ctrl.Result{}, err
164+
}
165+
154166
// Ensure all resources
155167
if result, err := r.ensureAllResources(ctx, vmcp, statusManager); err != nil {
156168
// Apply status changes before returning error
@@ -1268,7 +1280,10 @@ func (r *VirtualMCPServerReconciler) containerNeedsUpdate(
12681280
}
12691281

12701282
// Check if environment variables have changed
1271-
expectedEnv := r.buildEnvVarsForVmcp(ctx, vmcp, typedWorkloads)
1283+
expectedEnv, err := r.buildEnvVarsForVmcp(ctx, vmcp, typedWorkloads)
1284+
if err != nil {
1285+
return true // Trigger update to surface the error
1286+
}
12721287
if !reflect.DeepEqual(container.Env, expectedEnv) {
12731288
return true
12741289
}
@@ -2131,6 +2146,12 @@ func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
21312146
&mcpv1alpha1.EmbeddingServer{},
21322147
handler.EnqueueRequestsFromMapFunc(r.mapEmbeddingServerToVirtualMCPServer),
21332148
).
2149+
// Watch referenced MCPOIDCConfigs so that validity/hash changes
2150+
// trigger VirtualMCPServer reconciliation.
2151+
Watches(
2152+
&mcpv1alpha1.MCPOIDCConfig{},
2153+
handler.EnqueueRequestsFromMapFunc(r.mapOIDCConfigToVirtualMCPServer),
2154+
).
21342155
Complete(r)
21352156
}
21362157

@@ -2703,3 +2724,151 @@ func generateHMACSecret() (string, error) {
27032724
// Encode as base64 for safe storage and environment variable use
27042725
return base64.StdEncoding.EncodeToString(secret), nil
27052726
}
2727+
2728+
// handleOIDCConfig validates and tracks the hash of the referenced MCPOIDCConfig.
2729+
// It sets the OIDCConfigRefValidated condition and triggers reconciliation when
2730+
// the OIDC configuration changes.
2731+
func (r *VirtualMCPServerReconciler) handleOIDCConfig(
2732+
ctx context.Context,
2733+
vmcp *mcpv1alpha1.VirtualMCPServer,
2734+
statusManager virtualmcpserverstatus.StatusManager,
2735+
) error {
2736+
ctxLogger := log.FromContext(ctx)
2737+
2738+
if vmcp.Spec.IncomingAuth == nil || vmcp.Spec.IncomingAuth.OIDCConfigRef == nil {
2739+
// No MCPOIDCConfig referenced, clear any stored hash
2740+
if vmcp.Status.OIDCConfigHash != "" {
2741+
vmcp.Status.OIDCConfigHash = ""
2742+
if err := r.Status().Update(ctx, vmcp); err != nil {
2743+
return fmt.Errorf("failed to clear MCPOIDCConfig hash from status: %w", err)
2744+
}
2745+
}
2746+
return nil
2747+
}
2748+
2749+
ref := vmcp.Spec.IncomingAuth.OIDCConfigRef
2750+
2751+
// Get the referenced MCPOIDCConfig
2752+
oidcConfig, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, vmcp.Namespace, ref)
2753+
if err != nil {
2754+
statusManager.SetCondition(
2755+
mcpv1alpha1.ConditionOIDCConfigRefValidated,
2756+
mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound,
2757+
fmt.Sprintf("MCPOIDCConfig %s not found: %v", ref.Name, err),
2758+
metav1.ConditionFalse,
2759+
)
2760+
return err
2761+
}
2762+
2763+
if oidcConfig == nil {
2764+
statusManager.SetCondition(
2765+
mcpv1alpha1.ConditionOIDCConfigRefValidated,
2766+
mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound,
2767+
fmt.Sprintf("MCPOIDCConfig %s not found", ref.Name),
2768+
metav1.ConditionFalse,
2769+
)
2770+
return fmt.Errorf("MCPOIDCConfig %s not found", ref.Name)
2771+
}
2772+
2773+
// Check that the MCPOIDCConfig is valid
2774+
validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1alpha1.ConditionTypeOIDCConfigReady)
2775+
if validCondition == nil || validCondition.Status != metav1.ConditionTrue {
2776+
msg := fmt.Sprintf("MCPOIDCConfig %s is not valid", ref.Name)
2777+
if validCondition != nil {
2778+
msg = fmt.Sprintf("MCPOIDCConfig %s is not valid: %s", ref.Name, validCondition.Message)
2779+
}
2780+
statusManager.SetCondition(
2781+
mcpv1alpha1.ConditionOIDCConfigRefValidated,
2782+
mcpv1alpha1.ConditionReasonOIDCConfigRefNotValid,
2783+
msg,
2784+
metav1.ConditionFalse,
2785+
)
2786+
return fmt.Errorf("%s", msg)
2787+
}
2788+
2789+
// Update ReferencingWorkloads on the MCPOIDCConfig status
2790+
if err := r.updateOIDCConfigReferencingWorkloads(ctx, oidcConfig, vmcp.Name); err != nil {
2791+
ctxLogger.Error(err, "Failed to update MCPOIDCConfig ReferencingWorkloads")
2792+
// Non-fatal: continue with reconciliation
2793+
}
2794+
2795+
// Set valid condition
2796+
statusManager.SetCondition(
2797+
mcpv1alpha1.ConditionOIDCConfigRefValidated,
2798+
mcpv1alpha1.ConditionReasonOIDCConfigRefValid,
2799+
fmt.Sprintf("MCPOIDCConfig %s is valid and ready", ref.Name),
2800+
metav1.ConditionTrue,
2801+
)
2802+
2803+
// Check if the MCPOIDCConfig hash has changed
2804+
if vmcp.Status.OIDCConfigHash != oidcConfig.Status.ConfigHash {
2805+
ctxLogger.Info("MCPOIDCConfig has changed, updating VirtualMCPServer",
2806+
"vmcp", vmcp.Name,
2807+
"oidcConfig", oidcConfig.Name,
2808+
"oldHash", vmcp.Status.OIDCConfigHash,
2809+
"newHash", oidcConfig.Status.ConfigHash)
2810+
2811+
vmcp.Status.OIDCConfigHash = oidcConfig.Status.ConfigHash
2812+
if err := r.Status().Update(ctx, vmcp); err != nil {
2813+
return fmt.Errorf("failed to update MCPOIDCConfig hash in status: %w", err)
2814+
}
2815+
}
2816+
2817+
return nil
2818+
}
2819+
2820+
// updateOIDCConfigReferencingWorkloads ensures the VirtualMCPServer is listed in
2821+
// the MCPOIDCConfig's ReferencingWorkloads status field.
2822+
func (r *VirtualMCPServerReconciler) updateOIDCConfigReferencingWorkloads(
2823+
ctx context.Context,
2824+
oidcConfig *mcpv1alpha1.MCPOIDCConfig,
2825+
vmcpName string,
2826+
) error {
2827+
ref := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName}
2828+
// Check if already listed
2829+
for _, entry := range oidcConfig.Status.ReferencingWorkloads {
2830+
if entry.Kind == ref.Kind && entry.Name == ref.Name {
2831+
return nil
2832+
}
2833+
}
2834+
2835+
// Add the workload reference
2836+
oidcConfig.Status.ReferencingWorkloads = append(oidcConfig.Status.ReferencingWorkloads, ref)
2837+
if err := r.Status().Update(ctx, oidcConfig); err != nil {
2838+
return fmt.Errorf("failed to update MCPOIDCConfig ReferencingWorkloads: %w", err)
2839+
}
2840+
2841+
return nil
2842+
}
2843+
2844+
// mapOIDCConfigToVirtualMCPServer maps MCPOIDCConfig changes to VirtualMCPServer reconciliation requests.
2845+
func (r *VirtualMCPServerReconciler) mapOIDCConfigToVirtualMCPServer(
2846+
ctx context.Context, obj client.Object,
2847+
) []reconcile.Request {
2848+
oidcConfig, ok := obj.(*mcpv1alpha1.MCPOIDCConfig)
2849+
if !ok {
2850+
return nil
2851+
}
2852+
2853+
vmcpList := &mcpv1alpha1.VirtualMCPServerList{}
2854+
if err := r.List(ctx, vmcpList, client.InNamespace(oidcConfig.Namespace)); err != nil {
2855+
log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPOIDCConfig watch")
2856+
return nil
2857+
}
2858+
2859+
var requests []reconcile.Request
2860+
for _, vmcp := range vmcpList.Items {
2861+
if vmcp.Spec.IncomingAuth != nil &&
2862+
vmcp.Spec.IncomingAuth.OIDCConfigRef != nil &&
2863+
vmcp.Spec.IncomingAuth.OIDCConfigRef.Name == oidcConfig.Name {
2864+
requests = append(requests, reconcile.Request{
2865+
NamespacedName: types.NamespacedName{
2866+
Name: vmcp.Name,
2867+
Namespace: vmcp.Namespace,
2868+
},
2869+
})
2870+
}
2871+
}
2872+
2873+
return requests
2874+
}

0 commit comments

Comments
 (0)