@@ -6,10 +6,13 @@ import (
66
77 . "github.com/onsi/ginkgo/v2"
88 . "github.com/onsi/gomega"
9+ "gopkg.in/yaml.v3"
10+ corev1 "k8s.io/api/core/v1"
911 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1012 "k8s.io/apimachinery/pkg/types"
1113
1214 mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
15+ vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1316)
1417
1518var _ = Describe ("VirtualMCPServer CompositeToolDefinition Watch Integration Tests" , func () {
@@ -20,6 +23,32 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
2023 conditionReady = "Ready"
2124 )
2225
26+ // Helper function to get and parse the vmcp ConfigMap
27+ getVmcpConfig := func (namespace , vmcpName string ) (* vmcpconfig.Config , error ) {
28+ configMapName := vmcpName + "-vmcp-config"
29+ configMap := & corev1.ConfigMap {}
30+ err := k8sClient .Get (ctx , types.NamespacedName {
31+ Name : configMapName ,
32+ Namespace : namespace ,
33+ }, configMap )
34+ if err != nil {
35+ return nil , err
36+ }
37+
38+ // Parse the config.yaml from the ConfigMap
39+ configYAML , ok := configMap .Data ["config.yaml" ]
40+ if ! ok {
41+ return nil , nil // ConfigMap exists but no config.yaml
42+ }
43+
44+ var config vmcpconfig.Config
45+ if err := yaml .Unmarshal ([]byte (configYAML ), & config ); err != nil {
46+ return nil , err
47+ }
48+
49+ return & config , nil
50+ }
51+
2352 Context ("When a VirtualMCPCompositeToolDefinition is created after VirtualMCPServer" , Ordered , func () {
2453 var (
2554 namespace string
@@ -59,8 +88,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
5988 return err == nil && updatedGroup .Status .Phase == mcpv1alpha1 .MCPGroupPhaseReady
6089 }, timeout , interval ).Should (BeTrue ())
6190
62- // Create VirtualMCPServer that references the composite tool definition
63- // (even though the composite tool doesn't exist yet)
91+ // Create VirtualMCPServer with inline CompositeTools AND CompositeToolRefs
92+ // The inline tool will be used to verify reconciliation occurred
93+ // The CompositeToolRef will trigger the watch (but won't be resolved without the feature)
6494 vmcp = & mcpv1alpha1.VirtualMCPServer {
6595 ObjectMeta : metav1.ObjectMeta {
6696 Name : vmcpName ,
@@ -70,6 +100,18 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
70100 GroupRef : mcpv1alpha1.GroupRef {
71101 Name : mcpGroupName ,
72102 },
103+ CompositeTools : []mcpv1alpha1.CompositeToolSpec {
104+ {
105+ Name : "inline-tool" ,
106+ Description : "Inline composite tool for testing" ,
107+ Steps : []mcpv1alpha1.WorkflowStep {
108+ {
109+ ID : "inline-step1" ,
110+ Tool : "inline-tool1" ,
111+ },
112+ },
113+ },
114+ },
73115 CompositeToolRefs : []mcpv1alpha1.CompositeToolDefinitionRef {
74116 {Name : compositeToolDefName },
75117 },
@@ -117,23 +159,42 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
117159 }
118160 Expect (k8sClient .Create (ctx , compositeToolDef )).Should (Succeed ())
119161
120- // The VirtualMCPServer should remain reconciled after the composite tool definition is created
121- // We verify this by checking that ObservedGeneration stays current
122- Consistently (func () bool {
123- updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
124- err := k8sClient .Get (ctx , types.NamespacedName {
125- Name : vmcpName ,
126- Namespace : namespace ,
127- }, updatedVMCP )
128- if err != nil {
162+ // Verify that reconciliation occurred by checking the ConfigMap contains the INLINE composite tool
163+ // (We're not testing CompositeToolRef resolution - that's a separate feature)
164+ Eventually (func () bool {
165+ config , err := getVmcpConfig (namespace , vmcpName )
166+ if err != nil || config == nil {
129167 return false
130168 }
131169
132- // Check that ObservedGeneration stays current (indicating successful reconciliation)
133- return updatedVMCP .Status .ObservedGeneration == updatedVMCP .Generation
134- }, time .Second * 5 , interval ).Should (BeTrue ())
170+ // Check if the ConfigMap has the inline composite tool
171+ if len (config .CompositeTools ) == 0 {
172+ return false
173+ }
135174
136- // Verify the VirtualMCPServer is in a valid state
175+ // Find the inline composite tool by name
176+ for _ , tool := range config .CompositeTools {
177+ if tool .Name == "inline-tool" {
178+ return true
179+ }
180+ }
181+ return false
182+ }, timeout , interval ).Should (BeTrue (), "ConfigMap should contain inline composite tool after watch-triggered reconciliation" )
183+
184+ // Verify the inline composite tool content is correct (proves reconciliation completed successfully)
185+ config , err := getVmcpConfig (namespace , vmcpName )
186+ Expect (err ).ShouldNot (HaveOccurred ())
187+ Expect (config ).ShouldNot (BeNil ())
188+ Expect (config .CompositeTools ).Should (HaveLen (1 ), "Should have exactly 1 composite tool (inline only, CompositeToolRef not resolved yet)" )
189+
190+ compositeTool := config .CompositeTools [0 ]
191+ Expect (compositeTool .Name ).To (Equal ("inline-tool" ))
192+ Expect (compositeTool .Description ).To (Equal ("Inline composite tool for testing" ))
193+ Expect (compositeTool .Steps ).Should (HaveLen (1 ))
194+ Expect (compositeTool .Steps [0 ].ID ).To (Equal ("inline-step1" ))
195+ Expect (compositeTool .Steps [0 ].Tool ).To (Equal ("inline-tool1" ))
196+
197+ // Verify the VirtualMCPServer is in a valid state after reconciliation
137198 updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
138199 Expect (k8sClient .Get (ctx , types.NamespacedName {
139200 Name : vmcpName ,
@@ -206,7 +267,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
206267 }
207268 Expect (k8sClient .Create (ctx , compositeToolDef )).Should (Succeed ())
208269
209- // Create VirtualMCPServer that references the composite tool definition
270+ // Create VirtualMCPServer with inline CompositeTools AND CompositeToolRefs
210271 vmcp = & mcpv1alpha1.VirtualMCPServer {
211272 ObjectMeta : metav1.ObjectMeta {
212273 Name : vmcpName ,
@@ -216,6 +277,18 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
216277 GroupRef : mcpv1alpha1.GroupRef {
217278 Name : mcpGroupName ,
218279 },
280+ CompositeTools : []mcpv1alpha1.CompositeToolSpec {
281+ {
282+ Name : "inline-tool-update" ,
283+ Description : "Inline composite tool for update test" ,
284+ Steps : []mcpv1alpha1.WorkflowStep {
285+ {
286+ ID : "inline-step-update" ,
287+ Tool : "inline-tool-update1" ,
288+ },
289+ },
290+ },
291+ },
219292 CompositeToolRefs : []mcpv1alpha1.CompositeToolDefinitionRef {
220293 {Name : compositeToolDefName },
221294 },
@@ -242,7 +315,17 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
242315 })
243316
244317 It ("Should trigger VirtualMCPServer reconciliation when composite tool definition is updated" , func () {
318+ // Verify initial inline composite tool configuration exists
319+ config , err := getVmcpConfig (namespace , vmcpName )
320+ Expect (err ).ShouldNot (HaveOccurred ())
321+ Expect (config ).ShouldNot (BeNil ())
322+ Expect (config .CompositeTools ).Should (HaveLen (1 ), "Should have exactly 1 composite tool (inline only)" )
323+ Expect (config .CompositeTools [0 ].Name ).To (Equal ("inline-tool-update" ))
324+ Expect (config .CompositeTools [0 ].Description ).To (Equal ("Inline composite tool for update test" ))
325+
245326 // Update the VirtualMCPCompositeToolDefinition
327+ // This should trigger watch → reconciliation, but won't change the ConfigMap content
328+ // (since CompositeToolRefs resolution isn't implemented)
246329 Eventually (func () error {
247330 freshCompositeToolDef := & mcpv1alpha1.VirtualMCPCompositeToolDefinition {}
248331 if err := k8sClient .Get (ctx , types.NamespacedName {
@@ -255,21 +338,37 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
255338 return k8sClient .Update (ctx , freshCompositeToolDef )
256339 }, timeout , interval ).Should (Succeed ())
257340
258- // The VirtualMCPServer should remain reconciled after the update
259- // We verify this by checking that ObservedGeneration stays current
260- Consistently (func () bool {
261- updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
262- err := k8sClient .Get (ctx , types.NamespacedName {
263- Name : vmcpName ,
264- Namespace : namespace ,
265- }, updatedVMCP )
266- if err != nil {
341+ // Verify that reconciliation occurred by checking the ConfigMap still has the inline tool
342+ // (Reconciliation happened successfully, ConfigMap was regenerated with inline tool)
343+ Eventually (func () bool {
344+ config , err := getVmcpConfig (namespace , vmcpName )
345+ if err != nil || config == nil {
346+ return false
347+ }
348+
349+ // Check if the ConfigMap still has the inline composite tool (unchanged)
350+ if len (config .CompositeTools ) == 0 {
267351 return false
268352 }
269353
270- // Check that ObservedGeneration stays current (indicating successful reconciliation)
271- return updatedVMCP .Status .ObservedGeneration == updatedVMCP .Generation
272- }, time .Second * 5 , interval ).Should (BeTrue ())
354+ for _ , tool := range config .CompositeTools {
355+ if tool .Name == "inline-tool-update" {
356+ return true
357+ }
358+ }
359+ return false
360+ }, timeout , interval ).Should (BeTrue (), "ConfigMap should still contain inline composite tool after watch-triggered reconciliation" )
361+
362+ // Verify the inline composite tool content is correct (proves reconciliation completed successfully)
363+ config , err = getVmcpConfig (namespace , vmcpName )
364+ Expect (err ).ShouldNot (HaveOccurred ())
365+ Expect (config ).ShouldNot (BeNil ())
366+ Expect (config .CompositeTools ).Should (HaveLen (1 ), "Should have exactly 1 composite tool (inline only)" )
367+
368+ compositeTool := config .CompositeTools [0 ]
369+ Expect (compositeTool .Name ).To (Equal ("inline-tool-update" ))
370+ Expect (compositeTool .Description ).To (Equal ("Inline composite tool for update test" ))
371+ Expect (compositeTool .Steps ).Should (HaveLen (1 ))
273372
274373 // Verify the VirtualMCPServer is still in a valid state
275374 updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
@@ -359,13 +458,14 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
359458 })
360459
361460 It ("Should NOT trigger VirtualMCPServer reconciliation when unrelated composite tool definition is created" , func () {
362- // Get initial generation and observed generation
461+ // Get initial ResourceVersion and ObservedGeneration
363462 initialVMCP := & mcpv1alpha1.VirtualMCPServer {}
364463 Expect (k8sClient .Get (ctx , types.NamespacedName {
365464 Name : vmcpName ,
366465 Namespace : namespace ,
367466 }, initialVMCP )).Should (Succeed ())
368467
468+ initialResourceVersion := initialVMCP .ResourceVersion
369469 initialObservedGeneration := initialVMCP .Status .ObservedGeneration
370470
371471 var initialReadyTime metav1.Time
@@ -395,11 +495,26 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
395495 }
396496 Expect (k8sClient .Create (ctx , compositeToolDef )).Should (Succeed ())
397497
398- // Wait a bit to ensure any potential reconciliation would have occurred
399- time .Sleep (2 * time .Second )
400-
401498 // Verify that the VirtualMCPServer was NOT unnecessarily reconciled
402- // The ObservedGeneration should remain the same, and conditions shouldn't change
499+ // ResourceVersion and ObservedGeneration should remain unchanged
500+ Consistently (func () bool {
501+ updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
502+ err := k8sClient .Get (ctx , types.NamespacedName {
503+ Name : vmcpName ,
504+ Namespace : namespace ,
505+ }, updatedVMCP )
506+ if err != nil {
507+ return false
508+ }
509+
510+ // Verify ResourceVersion and ObservedGeneration haven't changed
511+ resourceVersionUnchanged := updatedVMCP .ResourceVersion == initialResourceVersion
512+ observedGenerationUnchanged := updatedVMCP .Status .ObservedGeneration == initialObservedGeneration
513+
514+ return resourceVersionUnchanged && observedGenerationUnchanged
515+ }, time .Second * 3 , interval ).Should (BeTrue (), "VirtualMCPServer should not be reconciled for unrelated composite tool" )
516+
517+ // Final verification of state
403518 updatedVMCP := & mcpv1alpha1.VirtualMCPServer {}
404519 Expect (k8sClient .Get (ctx , types.NamespacedName {
405520 Name : vmcpName ,
@@ -409,6 +524,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes
409524 // ObservedGeneration should be unchanged
410525 Expect (updatedVMCP .Status .ObservedGeneration ).To (Equal (initialObservedGeneration ))
411526
527+ // ResourceVersion should be unchanged
528+ Expect (updatedVMCP .ResourceVersion ).To (Equal (initialResourceVersion ))
529+
412530 // Ready condition timestamp should be unchanged
413531 for _ , cond := range updatedVMCP .Status .Conditions {
414532 if cond .Type == conditionReady {
0 commit comments