Skip to content

Commit 4935779

Browse files
ChrisJBurnsclaude
authored andcommitted
Replace ReferencingServers with ReferencingWorkloads on MCPToolConfig (stacklok#4506)
Replace ReferencingServers with structured ReferencingWorkloads on MCPToolConfig Migrate MCPToolConfig from plain string server names to structured WorkloadReference entries (kind + name) per RFC-0023, following the pattern already implemented on MCPOIDCConfig in PR stacklok#4492. - Replace ReferencingServers []string with ReferencingWorkloads []WorkloadReference - Add findReferencingWorkloads returning []WorkloadReference - Update handleDeletion to use DeletionBlocked condition with ReferencedByWorkloads reason - Update watch handler to check ref.Kind and ref.Name for stale entry cleanup - Update unit, edge-case, and integration tests for WorkloadReference assertions - Regenerate deepcopy, CRD manifests, and Helm templates Closes stacklok#4491 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c525a93 commit 4935779

9 files changed

Lines changed: 188 additions & 136 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,17 @@ type MCPToolConfigStatus struct {
9696
// +optional
9797
ConfigHash string `json:"configHash,omitempty"`
9898

99-
// ReferencingServers is a list of MCPServer resources that reference this MCPToolConfig
100-
// This helps track which servers need to be reconciled when this config changes
99+
// ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig.
100+
// Each entry identifies the workload by kind and name.
101101
// +optional
102-
ReferencingServers []string `json:"referencingServers,omitempty"`
102+
ReferencingWorkloads []WorkloadReference `json:"referencingWorkloads,omitempty"`
103103
}
104104

105105
// +kubebuilder:object:root=true
106106
// +kubebuilder:subresource:status
107107
// +kubebuilder:resource:shortName=tc;toolconfig,categories=toolhive
108108
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
109-
// +kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingServers`
109+
// +kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingWorkloads`
110110
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
111111

112112
// MCPToolConfig is the Schema for the mcptoolconfigs API.

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

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

cmd/thv-operator/controllers/toolconfig_controller.go

Lines changed: 96 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ func (r *ToolConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request)
9898
return r.handleConfigHashChange(ctx, toolConfig, configHash)
9999
}
100100

101+
// Refresh ReferencingWorkloads list
102+
referencingWorkloads, err := r.findReferencingWorkloads(ctx, toolConfig)
103+
if err != nil {
104+
logger.Error(err, "Failed to find referencing workloads")
105+
} else if !slices.Equal(toolConfig.Status.ReferencingWorkloads, referencingWorkloads) {
106+
toolConfig.Status.ReferencingWorkloads = referencingWorkloads
107+
conditionChanged = true
108+
}
109+
101110
// Update condition if it changed (even without hash change)
102111
if conditionChanged {
103112
if err := r.Status().Update(ctx, toolConfig); err != nil {
@@ -106,9 +115,7 @@ func (r *ToolConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request)
106115
}
107116
}
108117

109-
// Even when hash hasn't changed, update referencing servers list.
110-
// This ensures ReferencingServers is updated when MCPServers are created or deleted.
111-
return r.updateReferencingServers(ctx, toolConfig)
118+
return ctrl.Result{}, nil
112119
}
113120

114121
// handleConfigHashChange handles the logic when the config hash changes
@@ -134,13 +141,12 @@ func (r *ToolConfigReconciler) handleConfigHashChange(
134141
toolConfig.Status.ConfigHash = configHash
135142
toolConfig.Status.ObservedGeneration = toolConfig.Generation
136143

137-
// Update the status with the list of referencing servers
138-
serverNames := make([]string, 0, len(referencingServers))
144+
// Update the status with the list of referencing workloads
145+
refs := make([]mcpv1alpha1.WorkloadReference, 0, len(referencingServers))
139146
for _, server := range referencingServers {
140-
serverNames = append(serverNames, server.Name)
147+
refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: server.Name})
141148
}
142-
slices.Sort(serverNames)
143-
toolConfig.Status.ReferencingServers = serverNames
149+
toolConfig.Status.ReferencingWorkloads = refs
144150

145151
// Update the MCPToolConfig status
146152
if err := r.Status().Update(ctx, toolConfig); err != nil {
@@ -166,46 +172,6 @@ func (r *ToolConfigReconciler) handleConfigHashChange(
166172
return ctrl.Result{}, nil
167173
}
168174

169-
// updateReferencingServers finds referencing MCPServers and updates the status if the list changed
170-
func (r *ToolConfigReconciler) updateReferencingServers(
171-
ctx context.Context,
172-
toolConfig *mcpv1alpha1.MCPToolConfig,
173-
) (ctrl.Result, error) {
174-
referencingServers, err := r.findReferencingMCPServers(ctx, toolConfig)
175-
if err != nil {
176-
logger := log.FromContext(ctx)
177-
logger.Error(err, "Failed to find referencing MCPServers")
178-
meta.SetStatusCondition(&toolConfig.Status.Conditions, metav1.Condition{
179-
Type: mcpv1alpha1.ConditionToolConfigValid,
180-
Status: metav1.ConditionFalse,
181-
Reason: mcpv1alpha1.ConditionReasonToolConfigValidationFailed,
182-
Message: "Failed to find referencing MCPServers",
183-
ObservedGeneration: toolConfig.Generation,
184-
})
185-
if updateErr := r.Status().Update(ctx, toolConfig); updateErr != nil {
186-
logger.Error(updateErr, "Failed to update MCPToolConfig status after error")
187-
}
188-
return ctrl.Result{}, fmt.Errorf("failed to find referencing MCPServers: %w", err)
189-
}
190-
191-
serverNames := make([]string, 0, len(referencingServers))
192-
for _, server := range referencingServers {
193-
serverNames = append(serverNames, server.Name)
194-
}
195-
slices.Sort(serverNames)
196-
197-
if !slices.Equal(toolConfig.Status.ReferencingServers, serverNames) {
198-
toolConfig.Status.ReferencingServers = serverNames
199-
if err := r.Status().Update(ctx, toolConfig); err != nil {
200-
logger := log.FromContext(ctx)
201-
logger.Error(err, "Failed to update MCPToolConfig status")
202-
return ctrl.Result{}, err
203-
}
204-
}
205-
206-
return ctrl.Result{}, nil
207-
}
208-
209175
// calculateConfigHash calculates a hash of the MCPToolConfig spec using Kubernetes utilities
210176
func (*ToolConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPToolConfigSpec) string {
211177
return ctrlutil.CalculateConfigHash(spec)
@@ -216,31 +182,32 @@ func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *m
216182
logger := log.FromContext(ctx)
217183

218184
if controllerutil.ContainsFinalizer(toolConfig, ToolConfigFinalizerName) {
219-
// Check if any MCPServers are still referencing this MCPToolConfig
220-
referencingServers, err := r.findReferencingMCPServers(ctx, toolConfig)
185+
// Check if any workloads still reference this MCPToolConfig
186+
referencingWorkloads, err := r.findReferencingWorkloads(ctx, toolConfig)
221187
if err != nil {
222-
logger.Error(err, "Failed to find referencing MCPServers during deletion")
188+
logger.Error(err, "Failed to check referencing workloads during deletion")
223189
return ctrl.Result{}, err
224190
}
225191

226-
if len(referencingServers) > 0 {
227-
// Cannot delete - still referenced
228-
serverNames := make([]string, 0, len(referencingServers))
229-
for _, server := range referencingServers {
230-
serverNames = append(serverNames, server.Name)
192+
if len(referencingWorkloads) > 0 {
193+
logger.Info("MCPToolConfig is still referenced by workloads, blocking deletion",
194+
"toolconfig", toolConfig.Name,
195+
"referencingWorkloads", referencingWorkloads)
196+
197+
meta.SetStatusCondition(&toolConfig.Status.Conditions, metav1.Condition{
198+
Type: "DeletionBlocked",
199+
Status: metav1.ConditionTrue,
200+
Reason: "ReferencedByWorkloads",
201+
Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads),
202+
ObservedGeneration: toolConfig.Generation,
203+
})
204+
toolConfig.Status.ReferencingWorkloads = referencingWorkloads
205+
if updateErr := r.Status().Update(ctx, toolConfig); updateErr != nil {
206+
logger.Error(updateErr, "Failed to update status during deletion block")
231207
}
232-
logger.Info("Cannot delete MCPToolConfig - still referenced by MCPServers",
233-
"toolconfig", toolConfig.Name, "referencingServers", serverNames)
234208

235-
// Update status to show it's still referenced
236-
toolConfig.Status.ReferencingServers = serverNames
237-
if err := r.Status().Update(ctx, toolConfig); err != nil {
238-
logger.Error(err, "Failed to update MCPToolConfig status during deletion")
239-
}
240-
241-
// Return an error to prevent deletion
242-
return ctrl.Result{}, fmt.Errorf("MCPToolConfig %s is still referenced by MCPServers: %v",
243-
toolConfig.Name, serverNames)
209+
// Requeue to check again later
210+
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
244211
}
245212

246213
// No references, safe to remove finalizer and allow deletion
@@ -255,7 +222,28 @@ func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *m
255222
return ctrl.Result{}, nil
256223
}
257224

258-
// findReferencingMCPServers finds all MCPServers that reference the given MCPToolConfig
225+
// findReferencingWorkloads returns the workload resources (MCPServer)
226+
// that reference this MCPToolConfig via their ToolConfigRef field.
227+
func (r *ToolConfigReconciler) findReferencingWorkloads(
228+
ctx context.Context,
229+
toolConfig *mcpv1alpha1.MCPToolConfig,
230+
) ([]mcpv1alpha1.WorkloadReference, error) {
231+
mcpServerList := &mcpv1alpha1.MCPServerList{}
232+
if err := r.List(ctx, mcpServerList, client.InNamespace(toolConfig.Namespace)); err != nil {
233+
return nil, fmt.Errorf("failed to list MCPServers: %w", err)
234+
}
235+
236+
var refs []mcpv1alpha1.WorkloadReference
237+
for _, server := range mcpServerList.Items {
238+
if server.Spec.ToolConfigRef != nil && server.Spec.ToolConfigRef.Name == toolConfig.Name {
239+
refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: server.Name})
240+
}
241+
}
242+
return refs, nil
243+
}
244+
245+
// findReferencingMCPServers finds all MCPServers that reference the given MCPToolConfig.
246+
// Returns the full MCPServer objects, used by handleConfigHashChange to update server annotations.
259247
func (r *ToolConfigReconciler) findReferencingMCPServers(
260248
ctx context.Context,
261249
toolConfig *mcpv1alpha1.MCPToolConfig,
@@ -270,33 +258,59 @@ func (r *ToolConfigReconciler) findReferencingMCPServers(
270258
}
271259

272260
// SetupWithManager sets up the controller with the Manager.
261+
// Watches MCPServer changes to maintain accurate ReferencingWorkloads status.
273262
func (r *ToolConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
274-
// Create a handler that maps MCPServer changes to MCPToolConfig reconciliation requests.
275-
// When an MCPServer is created/updated/deleted, we need to reconcile the MCPToolConfig
276-
// it references so that the ReferencingServers status field stays up to date.
263+
// Watch MCPServer changes to update ReferencingWorkloads on referenced MCPToolConfigs.
264+
// This handler enqueues both the currently-referenced MCPToolConfig AND any
265+
// MCPToolConfig that still lists this server in ReferencingWorkloads (covers the
266+
// case where a server removes its toolConfigRef — the previously-referenced
267+
// config needs to reconcile and clean up the stale entry).
277268
toolConfigHandler := handler.EnqueueRequestsFromMapFunc(
278-
func(_ context.Context, obj client.Object) []reconcile.Request {
279-
mcpServer, ok := obj.(*mcpv1alpha1.MCPServer)
269+
func(ctx context.Context, obj client.Object) []reconcile.Request {
270+
server, ok := obj.(*mcpv1alpha1.MCPServer)
280271
if !ok {
281272
return nil
282273
}
283274

284-
if mcpServer.Spec.ToolConfigRef == nil {
285-
return nil
275+
seen := make(map[types.NamespacedName]struct{})
276+
var requests []reconcile.Request
277+
278+
// Enqueue the currently-referenced MCPToolConfig (if any)
279+
if server.Spec.ToolConfigRef != nil {
280+
nn := types.NamespacedName{
281+
Name: server.Spec.ToolConfigRef.Name,
282+
Namespace: server.Namespace,
283+
}
284+
seen[nn] = struct{}{}
285+
requests = append(requests, reconcile.Request{NamespacedName: nn})
286+
}
287+
288+
// Also enqueue any MCPToolConfig that still lists this server in
289+
// ReferencingWorkloads — handles ref-removal and server-deletion cases.
290+
toolConfigList := &mcpv1alpha1.MCPToolConfigList{}
291+
if err := r.List(ctx, toolConfigList, client.InNamespace(server.Namespace)); err != nil {
292+
log.FromContext(ctx).Error(err, "Failed to list MCPToolConfigs for MCPServer watch")
293+
return requests
294+
}
295+
for _, cfg := range toolConfigList.Items {
296+
nn := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
297+
if _, already := seen[nn]; already {
298+
continue
299+
}
300+
for _, ref := range cfg.Status.ReferencingWorkloads {
301+
if ref.Kind == "MCPServer" && ref.Name == server.Name {
302+
requests = append(requests, reconcile.Request{NamespacedName: nn})
303+
break
304+
}
305+
}
286306
}
287307

288-
return []reconcile.Request{{
289-
NamespacedName: types.NamespacedName{
290-
Name: mcpServer.Spec.ToolConfigRef.Name,
291-
Namespace: mcpServer.Namespace,
292-
},
293-
}}
308+
return requests
294309
},
295310
)
296311

297312
return ctrl.NewControllerManagedBy(mgr).
298313
For(&mcpv1alpha1.MCPToolConfig{}).
299-
// Watch for MCPServers and reconcile the MCPToolConfig when they change
300314
Watches(&mcpv1alpha1.MCPServer{}, toolConfigHandler).
301315
Complete(r)
302316
}

cmd/thv-operator/controllers/toolconfig_controller_edge_cases_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) {
122122
err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig)
123123
require.NoError(t, err)
124124
assert.NotEmpty(t, updatedConfig.Status.ConfigHash)
125-
assert.Contains(t, updatedConfig.Status.ReferencingServers, "test-server")
125+
assert.Contains(t, updatedConfig.Status.ReferencingWorkloads,
126+
mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "test-server"})
126127
})
127128

128129
t.Run("reconcile with changed spec", func(t *testing.T) {
@@ -350,10 +351,13 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) {
350351
var updatedConfig mcpv1alpha1.MCPToolConfig
351352
err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig)
352353
require.NoError(t, err)
353-
assert.Len(t, updatedConfig.Status.ReferencingServers, 3)
354-
assert.Contains(t, updatedConfig.Status.ReferencingServers, "server1")
355-
assert.Contains(t, updatedConfig.Status.ReferencingServers, "server2")
356-
assert.Contains(t, updatedConfig.Status.ReferencingServers, "server3")
354+
assert.Len(t, updatedConfig.Status.ReferencingWorkloads, 3)
355+
assert.Contains(t, updatedConfig.Status.ReferencingWorkloads,
356+
mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server1"})
357+
assert.Contains(t, updatedConfig.Status.ReferencingWorkloads,
358+
mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server2"})
359+
assert.Contains(t, updatedConfig.Status.ReferencingWorkloads,
360+
mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server3"})
357361
})
358362

359363
t.Run("empty MCPToolConfig spec", func(t *testing.T) {

0 commit comments

Comments
 (0)