Skip to content

Commit 625e33d

Browse files
committed
add test for composite workflow by reference
1 parent ccd1b8d commit 625e33d

File tree

1 file changed

+320
-0
lines changed

1 file changed

+320
-0
lines changed
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
package virtualmcp
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"time"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/runtime"
13+
"k8s.io/apimachinery/pkg/types"
14+
15+
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
16+
"github.com/stacklok/toolhive/test/e2e/images"
17+
)
18+
19+
var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func() {
20+
var (
21+
testNamespace = "default"
22+
mcpGroupName = "test-composite-ref-group"
23+
vmcpServerName = "test-vmcp-composite-ref"
24+
backendName = "yardstick-composite-ref"
25+
compositeToolDefName = "echo-twice-definition"
26+
timeout = 5 * time.Minute
27+
pollingInterval = 5 * time.Second
28+
vmcpNodePort int32
29+
30+
// Composite tool name
31+
compositeToolName = "echo_twice_ref"
32+
)
33+
34+
BeforeAll(func() {
35+
By("Creating MCPGroup for composite referenced test")
36+
CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, testNamespace,
37+
"Test MCP Group for composite referenced E2E tests", timeout, pollingInterval)
38+
39+
By("Creating yardstick backend MCPServer")
40+
CreateMCPServerAndWait(ctx, k8sClient, backendName, testNamespace, mcpGroupName,
41+
images.YardstickServerImage, timeout, pollingInterval)
42+
43+
// JSON Schema for composite tool parameters
44+
// Per MCP spec, inputSchema should be a JSON Schema object
45+
parameterSchema := map[string]interface{}{
46+
"type": "object",
47+
"properties": map[string]interface{}{
48+
"message": map[string]interface{}{
49+
"type": "string",
50+
"description": "The message to echo twice",
51+
},
52+
},
53+
"required": []string{"message"},
54+
}
55+
paramSchemaBytes, err := json.Marshal(parameterSchema)
56+
Expect(err).ToNot(HaveOccurred())
57+
58+
By("Creating VirtualMCPCompositeToolDefinition")
59+
compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{
60+
ObjectMeta: metav1.ObjectMeta{
61+
Name: compositeToolDefName,
62+
Namespace: testNamespace,
63+
},
64+
Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{
65+
Name: compositeToolName,
66+
Description: "Echoes the input message twice in sequence (referenced)",
67+
Parameters: &runtime.RawExtension{
68+
Raw: paramSchemaBytes,
69+
},
70+
Steps: []mcpv1alpha1.WorkflowStep{
71+
{
72+
ID: "first_echo",
73+
Type: "tool",
74+
// Use dot notation for tool references: backend.toolname
75+
Tool: fmt.Sprintf("%s.echo", backendName),
76+
Arguments: map[string]string{
77+
// Template expansion: use input parameter
78+
"input": "{{ .params.message }}",
79+
},
80+
},
81+
{
82+
ID: "second_echo",
83+
Type: "tool",
84+
// Use dot notation for tool references: backend.toolname
85+
Tool: fmt.Sprintf("%s.echo", backendName),
86+
DependsOn: []string{"first_echo"},
87+
Arguments: map[string]string{
88+
// Template expansion: use output from previous step
89+
"input": "{{ .steps.first_echo.result }}",
90+
},
91+
},
92+
},
93+
Timeout: "30s",
94+
},
95+
}
96+
Expect(k8sClient.Create(ctx, compositeToolDef)).To(Succeed())
97+
98+
By("Verifying VirtualMCPCompositeToolDefinition was created")
99+
// If creation succeeded, the webhook validation passed (no controller sets status)
100+
Eventually(func() bool {
101+
def := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{}
102+
err := k8sClient.Get(ctx, types.NamespacedName{
103+
Name: compositeToolDefName,
104+
Namespace: testNamespace,
105+
}, def)
106+
return err == nil
107+
}, 30*time.Second, pollingInterval).Should(BeTrue(), "VirtualMCPCompositeToolDefinition should exist")
108+
109+
By("Creating VirtualMCPServer with referenced composite tool")
110+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
111+
ObjectMeta: metav1.ObjectMeta{
112+
Name: vmcpServerName,
113+
Namespace: testNamespace,
114+
},
115+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
116+
GroupRef: mcpv1alpha1.GroupRef{
117+
Name: mcpGroupName,
118+
},
119+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
120+
Type: "anonymous",
121+
},
122+
Aggregation: &mcpv1alpha1.AggregationConfig{
123+
ConflictResolution: "prefix",
124+
},
125+
// Reference the composite tool definition instead of defining inline
126+
CompositeToolRefs: []mcpv1alpha1.CompositeToolDefinitionRef{
127+
{
128+
Name: compositeToolDefName,
129+
},
130+
},
131+
ServiceType: "NodePort",
132+
},
133+
}
134+
Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed())
135+
136+
By("Waiting for VirtualMCPServer to be ready")
137+
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout)
138+
139+
By("Getting NodePort for VirtualMCPServer")
140+
vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
141+
142+
By(fmt.Sprintf("VirtualMCPServer accessible at http://localhost:%d", vmcpNodePort))
143+
})
144+
145+
AfterAll(func() {
146+
By("Cleaning up VirtualMCPServer")
147+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
148+
ObjectMeta: metav1.ObjectMeta{
149+
Name: vmcpServerName,
150+
Namespace: testNamespace,
151+
},
152+
}
153+
_ = k8sClient.Delete(ctx, vmcpServer)
154+
155+
By("Cleaning up VirtualMCPCompositeToolDefinition")
156+
compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{
157+
ObjectMeta: metav1.ObjectMeta{
158+
Name: compositeToolDefName,
159+
Namespace: testNamespace,
160+
},
161+
}
162+
_ = k8sClient.Delete(ctx, compositeToolDef)
163+
164+
By("Cleaning up backend MCPServer")
165+
backend := &mcpv1alpha1.MCPServer{
166+
ObjectMeta: metav1.ObjectMeta{
167+
Name: backendName,
168+
Namespace: testNamespace,
169+
},
170+
}
171+
_ = k8sClient.Delete(ctx, backend)
172+
173+
By("Cleaning up MCPGroup")
174+
mcpGroup := &mcpv1alpha1.MCPGroup{
175+
ObjectMeta: metav1.ObjectMeta{
176+
Name: mcpGroupName,
177+
Namespace: testNamespace,
178+
},
179+
}
180+
_ = k8sClient.Delete(ctx, mcpGroup)
181+
})
182+
183+
Context("when composite tools are referenced", func() {
184+
It("should expose the referenced composite tool in tool listing", func() {
185+
By("Creating and initializing MCP client for VirtualMCPServer")
186+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-composite-ref-test", 30*time.Second)
187+
Expect(err).ToNot(HaveOccurred())
188+
defer mcpClient.Close()
189+
190+
By("Listing tools from VirtualMCPServer")
191+
listRequest := mcp.ListToolsRequest{}
192+
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
193+
Expect(err).ToNot(HaveOccurred())
194+
195+
By(fmt.Sprintf("VirtualMCPServer exposes %d tools", len(tools.Tools)))
196+
for _, tool := range tools.Tools {
197+
GinkgoWriter.Printf(" Tool: %s - %s\n", tool.Name, tool.Description)
198+
}
199+
200+
// Should find the referenced composite tool
201+
var foundComposite bool
202+
for _, tool := range tools.Tools {
203+
if tool.Name == compositeToolName {
204+
foundComposite = true
205+
Expect(tool.Description).To(Equal("Echoes the input message twice in sequence (referenced)"))
206+
break
207+
}
208+
}
209+
Expect(foundComposite).To(BeTrue(), "Should find referenced composite tool: %s", compositeToolName)
210+
211+
// Should also have the backend's native echo tool (with prefix)
212+
var foundBackendTool bool
213+
expectedBackendTool := fmt.Sprintf("%s_echo", backendName)
214+
for _, tool := range tools.Tools {
215+
if tool.Name == expectedBackendTool {
216+
foundBackendTool = true
217+
break
218+
}
219+
}
220+
Expect(foundBackendTool).To(BeTrue(), "Should find backend native tool: %s", expectedBackendTool)
221+
})
222+
223+
It("should execute referenced composite tool with sequential workflow", func() {
224+
By("Creating and initializing MCP client for VirtualMCPServer")
225+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-composite-ref-test", 30*time.Second)
226+
Expect(err).ToNot(HaveOccurred())
227+
defer mcpClient.Close()
228+
229+
By("Calling referenced composite tool with test message")
230+
testMessage := "hello_referenced_test"
231+
callRequest := mcp.CallToolRequest{}
232+
callRequest.Params.Name = compositeToolName
233+
callRequest.Params.Arguments = map[string]any{
234+
"message": testMessage,
235+
}
236+
237+
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
238+
Expect(err).ToNot(HaveOccurred(), "Referenced composite tool call should succeed")
239+
Expect(result).ToNot(BeNil())
240+
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
241+
242+
// The result should reflect the sequential execution
243+
// First echo: echoes testMessage
244+
// Second echo: echoes the result of first echo
245+
GinkgoWriter.Printf("Referenced composite tool result: %+v\n", result.Content)
246+
})
247+
})
248+
249+
Context("when verifying referenced composite tool configuration", func() {
250+
It("should have correct CompositeToolRefs in VirtualMCPServer", func() {
251+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
252+
err := k8sClient.Get(ctx, types.NamespacedName{
253+
Name: vmcpServerName,
254+
Namespace: testNamespace,
255+
}, vmcpServer)
256+
Expect(err).ToNot(HaveOccurred())
257+
258+
// Should use CompositeToolRefs, not inline CompositeTools
259+
Expect(vmcpServer.Spec.CompositeTools).To(BeEmpty(), "Should not have inline composite tools")
260+
Expect(vmcpServer.Spec.CompositeToolRefs).To(HaveLen(1), "Should have one composite tool reference")
261+
262+
ref := vmcpServer.Spec.CompositeToolRefs[0]
263+
Expect(ref.Name).To(Equal(compositeToolDefName))
264+
})
265+
266+
It("should have correct composite tool definition stored", func() {
267+
compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{}
268+
err := k8sClient.Get(ctx, types.NamespacedName{
269+
Name: compositeToolDefName,
270+
Namespace: testNamespace,
271+
}, compositeToolDef)
272+
Expect(err).ToNot(HaveOccurred())
273+
274+
// Verify the definition spec
275+
Expect(compositeToolDef.Spec.Name).To(Equal(compositeToolName))
276+
Expect(compositeToolDef.Spec.Steps).To(HaveLen(2))
277+
278+
// Verify step dependencies
279+
step1 := compositeToolDef.Spec.Steps[0]
280+
Expect(step1.ID).To(Equal("first_echo"))
281+
Expect(step1.DependsOn).To(BeEmpty())
282+
283+
step2 := compositeToolDef.Spec.Steps[1]
284+
Expect(step2.ID).To(Equal("second_echo"))
285+
Expect(step2.DependsOn).To(ContainElement("first_echo"))
286+
287+
// Verify template usage in arguments
288+
Expect(step1.Arguments["input"]).To(ContainSubstring(".params.message"))
289+
Expect(step2.Arguments["input"]).To(ContainSubstring(".steps.first_echo"))
290+
291+
// Note: ValidationStatus is not set because there's no controller for VirtualMCPCompositeToolDefinition
292+
// If the resource exists, it means webhook validation passed
293+
})
294+
295+
It("should reflect referenced tool in VirtualMCPServer status", func() {
296+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
297+
err := k8sClient.Get(ctx, types.NamespacedName{
298+
Name: vmcpServerName,
299+
Namespace: testNamespace,
300+
}, vmcpServer)
301+
Expect(err).ToNot(HaveOccurred())
302+
303+
// Check that VirtualMCPServer is in Ready phase
304+
Expect(vmcpServer.Status.Phase).To(Equal(mcpv1alpha1.VirtualMCPServerPhaseReady),
305+
"VirtualMCPServer should be in Ready phase when using valid CompositeToolRefs")
306+
307+
// Check for CompositeToolRefsValidated condition (if it exists)
308+
// Note: This condition might not always be set immediately
309+
for _, condition := range vmcpServer.Status.Conditions {
310+
if condition.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated {
311+
Expect(condition.Status).To(Equal(metav1.ConditionTrue),
312+
"CompositeToolRefs should be validated")
313+
Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonCompositeToolRefsValid))
314+
GinkgoWriter.Printf("Found CompositeToolRefsValidated condition: %s\n", condition.Message)
315+
break
316+
}
317+
}
318+
})
319+
})
320+
})

0 commit comments

Comments
 (0)