Skip to content

Commit 1f5a7c0

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

File tree

1 file changed

+318
-0
lines changed

1 file changed

+318
-0
lines changed
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
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.CompositeToolDefinitionSpec{
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+
Tool: fmt.Sprintf("%s.echo", backendName),
75+
Arguments: map[string]string{
76+
// Template expansion: use input parameter
77+
"input": "{{ .params.message }}",
78+
},
79+
},
80+
{
81+
ID: "second_echo",
82+
Type: "tool",
83+
Tool: fmt.Sprintf("%s.echo", backendName),
84+
DependsOn: []string{"first_echo"},
85+
Arguments: map[string]string{
86+
// Template expansion: use output from previous step
87+
"input": "{{ .steps.first_echo.result }}",
88+
},
89+
},
90+
},
91+
Timeout: "30s",
92+
},
93+
}
94+
Expect(k8sClient.Create(ctx, compositeToolDef)).To(Succeed())
95+
96+
By("Waiting for VirtualMCPCompositeToolDefinition to be validated")
97+
Eventually(func() bool {
98+
def := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{}
99+
err := k8sClient.Get(ctx, types.NamespacedName{
100+
Name: compositeToolDefName,
101+
Namespace: testNamespace,
102+
}, def)
103+
if err != nil {
104+
return false
105+
}
106+
// Check if validation status is set and valid
107+
return def.Status.ValidationStatus == mcpv1alpha1.ValidationStatusValid
108+
}, timeout, pollingInterval).Should(BeTrue(), "VirtualMCPCompositeToolDefinition should be validated")
109+
110+
By("Creating VirtualMCPServer with referenced composite tool")
111+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
112+
ObjectMeta: metav1.ObjectMeta{
113+
Name: vmcpServerName,
114+
Namespace: testNamespace,
115+
},
116+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
117+
GroupRef: mcpv1alpha1.GroupRef{
118+
Name: mcpGroupName,
119+
},
120+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
121+
Type: "anonymous",
122+
},
123+
Aggregation: &mcpv1alpha1.AggregationConfig{
124+
ConflictResolution: "prefix",
125+
},
126+
// Reference the composite tool definition instead of defining inline
127+
CompositeToolRefs: []mcpv1alpha1.CompositeToolRef{
128+
{
129+
Name: compositeToolDefName,
130+
},
131+
},
132+
ServiceType: "NodePort",
133+
},
134+
}
135+
Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed())
136+
137+
By("Waiting for VirtualMCPServer to be ready")
138+
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout)
139+
140+
By("Getting NodePort for VirtualMCPServer")
141+
vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
142+
143+
By(fmt.Sprintf("VirtualMCPServer accessible at http://localhost:%d", vmcpNodePort))
144+
})
145+
146+
AfterAll(func() {
147+
By("Cleaning up VirtualMCPServer")
148+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
149+
ObjectMeta: metav1.ObjectMeta{
150+
Name: vmcpServerName,
151+
Namespace: testNamespace,
152+
},
153+
}
154+
_ = k8sClient.Delete(ctx, vmcpServer)
155+
156+
By("Cleaning up VirtualMCPCompositeToolDefinition")
157+
compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{
158+
ObjectMeta: metav1.ObjectMeta{
159+
Name: compositeToolDefName,
160+
Namespace: testNamespace,
161+
},
162+
}
163+
_ = k8sClient.Delete(ctx, compositeToolDef)
164+
165+
By("Cleaning up backend MCPServer")
166+
backend := &mcpv1alpha1.MCPServer{
167+
ObjectMeta: metav1.ObjectMeta{
168+
Name: backendName,
169+
Namespace: testNamespace,
170+
},
171+
}
172+
_ = k8sClient.Delete(ctx, backend)
173+
174+
By("Cleaning up MCPGroup")
175+
mcpGroup := &mcpv1alpha1.MCPGroup{
176+
ObjectMeta: metav1.ObjectMeta{
177+
Name: mcpGroupName,
178+
Namespace: testNamespace,
179+
},
180+
}
181+
_ = k8sClient.Delete(ctx, mcpGroup)
182+
})
183+
184+
Context("when composite tools are referenced", func() {
185+
It("should expose the referenced composite tool in tool listing", func() {
186+
By("Creating and initializing MCP client for VirtualMCPServer")
187+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-composite-ref-test", 30*time.Second)
188+
Expect(err).ToNot(HaveOccurred())
189+
defer mcpClient.Close()
190+
191+
By("Listing tools from VirtualMCPServer")
192+
listRequest := mcp.ListToolsRequest{}
193+
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
194+
Expect(err).ToNot(HaveOccurred())
195+
196+
By(fmt.Sprintf("VirtualMCPServer exposes %d tools", len(tools.Tools)))
197+
for _, tool := range tools.Tools {
198+
GinkgoWriter.Printf(" Tool: %s - %s\n", tool.Name, tool.Description)
199+
}
200+
201+
// Should find the referenced composite tool
202+
var foundComposite bool
203+
for _, tool := range tools.Tools {
204+
if tool.Name == compositeToolName {
205+
foundComposite = true
206+
Expect(tool.Description).To(Equal("Echoes the input message twice in sequence (referenced)"))
207+
break
208+
}
209+
}
210+
Expect(foundComposite).To(BeTrue(), "Should find referenced composite tool: %s", compositeToolName)
211+
212+
// Should also have the backend's native echo tool (with prefix)
213+
var foundBackendTool bool
214+
expectedBackendTool := fmt.Sprintf("%s_echo", backendName)
215+
for _, tool := range tools.Tools {
216+
if tool.Name == expectedBackendTool {
217+
foundBackendTool = true
218+
break
219+
}
220+
}
221+
Expect(foundBackendTool).To(BeTrue(), "Should find backend native tool: %s", expectedBackendTool)
222+
})
223+
224+
It("should execute referenced composite tool with sequential workflow", func() {
225+
By("Creating and initializing MCP client for VirtualMCPServer")
226+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-composite-ref-test", 30*time.Second)
227+
Expect(err).ToNot(HaveOccurred())
228+
defer mcpClient.Close()
229+
230+
By("Calling referenced composite tool with test message")
231+
testMessage := "hello_referenced_test"
232+
callRequest := mcp.CallToolRequest{}
233+
callRequest.Params.Name = compositeToolName
234+
callRequest.Params.Arguments = map[string]any{
235+
"message": testMessage,
236+
}
237+
238+
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
239+
Expect(err).ToNot(HaveOccurred(), "Referenced composite tool call should succeed")
240+
Expect(result).ToNot(BeNil())
241+
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
242+
243+
// The result should reflect the sequential execution
244+
// First echo: echoes testMessage
245+
// Second echo: echoes the result of first echo
246+
GinkgoWriter.Printf("Referenced composite tool result: %+v\n", result.Content)
247+
})
248+
})
249+
250+
Context("when verifying referenced composite tool configuration", func() {
251+
It("should have correct CompositeToolRefs in VirtualMCPServer", func() {
252+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
253+
err := k8sClient.Get(ctx, types.NamespacedName{
254+
Name: vmcpServerName,
255+
Namespace: testNamespace,
256+
}, vmcpServer)
257+
Expect(err).ToNot(HaveOccurred())
258+
259+
// Should use CompositeToolRefs, not inline CompositeTools
260+
Expect(vmcpServer.Spec.CompositeTools).To(BeEmpty(), "Should not have inline composite tools")
261+
Expect(vmcpServer.Spec.CompositeToolRefs).To(HaveLen(1), "Should have one composite tool reference")
262+
263+
ref := vmcpServer.Spec.CompositeToolRefs[0]
264+
Expect(ref.Name).To(Equal(compositeToolDefName))
265+
})
266+
267+
It("should have correct composite tool definition stored", func() {
268+
compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{}
269+
err := k8sClient.Get(ctx, types.NamespacedName{
270+
Name: compositeToolDefName,
271+
Namespace: testNamespace,
272+
}, compositeToolDef)
273+
Expect(err).ToNot(HaveOccurred())
274+
275+
// Verify the definition spec
276+
Expect(compositeToolDef.Spec.Name).To(Equal(compositeToolName))
277+
Expect(compositeToolDef.Spec.Steps).To(HaveLen(2))
278+
279+
// Verify step dependencies
280+
step1 := compositeToolDef.Spec.Steps[0]
281+
Expect(step1.ID).To(Equal("first_echo"))
282+
Expect(step1.DependsOn).To(BeEmpty())
283+
284+
step2 := compositeToolDef.Spec.Steps[1]
285+
Expect(step2.ID).To(Equal("second_echo"))
286+
Expect(step2.DependsOn).To(ContainElement("first_echo"))
287+
288+
// Verify template usage in arguments
289+
Expect(step1.Arguments["input"]).To(ContainSubstring(".params.message"))
290+
Expect(step2.Arguments["input"]).To(ContainSubstring(".steps.first_echo"))
291+
292+
// Verify validation status
293+
Expect(compositeToolDef.Status.ValidationStatus).To(Equal(mcpv1alpha1.ValidationStatusValid))
294+
})
295+
296+
It("should reflect referenced tool in VirtualMCPServer status conditions", func() {
297+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
298+
err := k8sClient.Get(ctx, types.NamespacedName{
299+
Name: vmcpServerName,
300+
Namespace: testNamespace,
301+
}, vmcpServer)
302+
Expect(err).ToNot(HaveOccurred())
303+
304+
// Check for CompositeToolRefsValidated condition
305+
var foundCondition bool
306+
for _, condition := range vmcpServer.Status.Conditions {
307+
if condition.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated {
308+
foundCondition = true
309+
Expect(condition.Status).To(Equal(metav1.ConditionTrue),
310+
"CompositeToolRefs should be validated")
311+
Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonCompositeToolRefsValid))
312+
break
313+
}
314+
}
315+
Expect(foundCondition).To(BeTrue(), "Should have CompositeToolRefsValidated condition")
316+
})
317+
})
318+
})

0 commit comments

Comments
 (0)