Skip to content

Commit efe86fa

Browse files
JAORMXclaude
andauthored
Add vMCP e2e tests for tool overrides and composite workflows (#2826)
* Add vMCP e2e tests for tool overrides and composite workflows Add three new e2e test files for VirtualMCPServer: - virtualmcp_aggregation_overrides_test.go: Tests tool renaming via overrides configuration, verifying renamed tools appear with custom names and descriptions while original names are hidden. - virtualmcp_composite_sequential_test.go: Tests sequential composite tool workflows with template expansion, where step B depends on step A's output using Go template syntax. - virtualmcp_composite_parallel_test.go: Tests parallel composite workflows where independent steps execute concurrently before aggregating results in a final dependent step. All tests use yardstick as a deterministic MCP server backend. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix tool override test to use user-facing name in filter The filtering logic applies after tool overrides, so the filter must specify the renamed tool name (user-facing name) rather than the original name. Updated test to filter by renamedToolName. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent c89f49a commit efe86fa

File tree

3 files changed

+986
-0
lines changed

3 files changed

+986
-0
lines changed
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package virtualmcp
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/mark3labs/mcp-go/mcp"
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/types"
13+
14+
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
15+
"github.com/stacklok/toolhive/test/e2e/images"
16+
)
17+
18+
// Compile-time check to ensure corev1 is used (for Service type)
19+
var _ = corev1.ServiceSpec{}
20+
21+
var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() {
22+
var (
23+
testNamespace = "default"
24+
mcpGroupName = "test-overrides-group"
25+
vmcpServerName = "test-vmcp-overrides"
26+
backendName = "yardstick-override"
27+
timeout = 5 * time.Minute
28+
pollingInterval = 5 * time.Second
29+
vmcpNodePort int32
30+
31+
// The original and renamed tool names
32+
originalToolName = "echo"
33+
renamedToolName = "custom_echo_tool"
34+
newDescription = "A renamed echo tool with custom description"
35+
)
36+
37+
vmcpServiceName := func() string {
38+
return fmt.Sprintf("vmcp-%s", vmcpServerName)
39+
}
40+
41+
BeforeAll(func() {
42+
By("Creating MCPGroup for overrides test")
43+
mcpGroup := &mcpv1alpha1.MCPGroup{
44+
ObjectMeta: metav1.ObjectMeta{
45+
Name: mcpGroupName,
46+
Namespace: testNamespace,
47+
},
48+
Spec: mcpv1alpha1.MCPGroupSpec{
49+
Description: "Test MCP Group for tool overrides E2E tests",
50+
},
51+
}
52+
Expect(k8sClient.Create(ctx, mcpGroup)).To(Succeed())
53+
54+
By("Waiting for MCPGroup to be ready")
55+
Eventually(func() bool {
56+
err := k8sClient.Get(ctx, types.NamespacedName{
57+
Name: mcpGroupName,
58+
Namespace: testNamespace,
59+
}, mcpGroup)
60+
if err != nil {
61+
return false
62+
}
63+
return mcpGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady
64+
}, timeout, pollingInterval).Should(BeTrue())
65+
66+
By("Creating yardstick backend MCPServer")
67+
backend := &mcpv1alpha1.MCPServer{
68+
ObjectMeta: metav1.ObjectMeta{
69+
Name: backendName,
70+
Namespace: testNamespace,
71+
},
72+
Spec: mcpv1alpha1.MCPServerSpec{
73+
GroupRef: mcpGroupName,
74+
Image: images.YardstickServerImage,
75+
Transport: "streamable-http",
76+
ProxyPort: 8080,
77+
McpPort: 8080,
78+
Env: []mcpv1alpha1.EnvVar{
79+
{Name: "TRANSPORT", Value: "streamable-http"},
80+
},
81+
},
82+
}
83+
Expect(k8sClient.Create(ctx, backend)).To(Succeed())
84+
85+
By("Waiting for backend MCPServer to be ready")
86+
Eventually(func() error {
87+
server := &mcpv1alpha1.MCPServer{}
88+
err := k8sClient.Get(ctx, types.NamespacedName{
89+
Name: backendName,
90+
Namespace: testNamespace,
91+
}, server)
92+
if err != nil {
93+
return fmt.Errorf("failed to get server: %w", err)
94+
}
95+
if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning {
96+
return nil
97+
}
98+
return fmt.Errorf("%s not ready yet, phase: %s", backendName, server.Status.Phase)
99+
}, timeout, pollingInterval).Should(Succeed(), "Backend should be ready")
100+
101+
By("Creating VirtualMCPServer with tool overrides")
102+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
103+
ObjectMeta: metav1.ObjectMeta{
104+
Name: vmcpServerName,
105+
Namespace: testNamespace,
106+
},
107+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
108+
GroupRef: mcpv1alpha1.GroupRef{
109+
Name: mcpGroupName,
110+
},
111+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
112+
Type: "anonymous",
113+
},
114+
Aggregation: &mcpv1alpha1.AggregationConfig{
115+
ConflictResolution: "prefix",
116+
// Tool overrides: rename echo to custom_echo_tool with new description
117+
// Note: Filter uses the user-facing name (after override), so we filter by
118+
// the renamed tool name, not the original name.
119+
Tools: []mcpv1alpha1.WorkloadToolConfig{
120+
{
121+
Workload: backendName,
122+
Filter: []string{renamedToolName}, // Filter by user-facing name (after override)
123+
Overrides: map[string]mcpv1alpha1.ToolOverride{
124+
originalToolName: {
125+
Name: renamedToolName,
126+
Description: newDescription,
127+
},
128+
},
129+
},
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+
Eventually(func() error {
142+
service := &corev1.Service{}
143+
serviceName := vmcpServiceName()
144+
err := k8sClient.Get(ctx, types.NamespacedName{
145+
Name: serviceName,
146+
Namespace: testNamespace,
147+
}, service)
148+
if err != nil {
149+
return err
150+
}
151+
if len(service.Spec.Ports) == 0 || service.Spec.Ports[0].NodePort == 0 {
152+
return fmt.Errorf("nodePort not assigned for vmcp")
153+
}
154+
vmcpNodePort = service.Spec.Ports[0].NodePort
155+
return nil
156+
}, timeout, pollingInterval).Should(Succeed())
157+
158+
By(fmt.Sprintf("VirtualMCPServer accessible at http://localhost:%d", vmcpNodePort))
159+
})
160+
161+
AfterAll(func() {
162+
By("Cleaning up VirtualMCPServer")
163+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
164+
ObjectMeta: metav1.ObjectMeta{
165+
Name: vmcpServerName,
166+
Namespace: testNamespace,
167+
},
168+
}
169+
_ = k8sClient.Delete(ctx, vmcpServer)
170+
171+
By("Cleaning up backend MCPServer")
172+
backend := &mcpv1alpha1.MCPServer{
173+
ObjectMeta: metav1.ObjectMeta{
174+
Name: backendName,
175+
Namespace: testNamespace,
176+
},
177+
}
178+
_ = k8sClient.Delete(ctx, backend)
179+
180+
By("Cleaning up MCPGroup")
181+
mcpGroup := &mcpv1alpha1.MCPGroup{
182+
ObjectMeta: metav1.ObjectMeta{
183+
Name: mcpGroupName,
184+
Namespace: testNamespace,
185+
},
186+
}
187+
_ = k8sClient.Delete(ctx, mcpGroup)
188+
})
189+
190+
Context("when tool overrides are configured", func() {
191+
It("should expose tools with renamed names", func() {
192+
By("Creating and initializing MCP client for VirtualMCPServer")
193+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-overrides-test", 30*time.Second)
194+
Expect(err).ToNot(HaveOccurred())
195+
defer mcpClient.Close()
196+
197+
By("Listing tools from VirtualMCPServer")
198+
listRequest := mcp.ListToolsRequest{}
199+
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
200+
Expect(err).ToNot(HaveOccurred())
201+
202+
By(fmt.Sprintf("VirtualMCPServer exposes %d tools", len(tools.Tools)))
203+
for _, tool := range tools.Tools {
204+
GinkgoWriter.Printf(" Tool: %s - %s\n", tool.Name, tool.Description)
205+
}
206+
207+
// Should have the renamed tool
208+
var foundTool *mcp.Tool
209+
for i := range tools.Tools {
210+
tool := &tools.Tools[i]
211+
// Tool name will be prefixed with workload name due to prefix conflict resolution
212+
// Format: {workload}_{original_or_renamed_tool}
213+
if tool.Name == fmt.Sprintf("%s_%s", backendName, renamedToolName) {
214+
foundTool = tool
215+
break
216+
}
217+
}
218+
219+
Expect(foundTool).ToNot(BeNil(), "Should find renamed tool: %s_%s", backendName, renamedToolName)
220+
Expect(foundTool.Description).To(Equal(newDescription), "Tool should have the custom description")
221+
})
222+
223+
It("should NOT expose the original tool name", func() {
224+
By("Creating and initializing MCP client for VirtualMCPServer")
225+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-overrides-test", 30*time.Second)
226+
Expect(err).ToNot(HaveOccurred())
227+
defer mcpClient.Close()
228+
229+
By("Listing tools from VirtualMCPServer")
230+
listRequest := mcp.ListToolsRequest{}
231+
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
232+
Expect(err).ToNot(HaveOccurred())
233+
234+
// Should NOT have the original tool name
235+
for _, tool := range tools.Tools {
236+
originalWithPrefix := fmt.Sprintf("%s_%s", backendName, originalToolName)
237+
Expect(tool.Name).ToNot(Equal(originalWithPrefix),
238+
"Original tool name should not be exposed when renamed")
239+
}
240+
})
241+
242+
It("should allow calling the renamed tool", func() {
243+
By("Creating and initializing MCP client for VirtualMCPServer")
244+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-overrides-test", 30*time.Second)
245+
Expect(err).ToNot(HaveOccurred())
246+
defer mcpClient.Close()
247+
248+
renamedToolFullName := fmt.Sprintf("%s_%s", backendName, renamedToolName)
249+
By(fmt.Sprintf("Calling renamed tool: %s", renamedToolFullName))
250+
251+
testInput := "override_test_123"
252+
callRequest := mcp.CallToolRequest{}
253+
callRequest.Params.Name = renamedToolFullName
254+
callRequest.Params.Arguments = map[string]any{
255+
"input": testInput,
256+
}
257+
258+
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
259+
Expect(err).ToNot(HaveOccurred(), "Should be able to call renamed tool")
260+
Expect(result).ToNot(BeNil())
261+
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
262+
263+
// Yardstick echo tool echoes back the input
264+
GinkgoWriter.Printf("Renamed tool call result: %+v\n", result.Content)
265+
})
266+
})
267+
268+
Context("when verifying override configuration", func() {
269+
It("should have correct aggregation configuration with tool overrides", func() {
270+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
271+
err := k8sClient.Get(ctx, types.NamespacedName{
272+
Name: vmcpServerName,
273+
Namespace: testNamespace,
274+
}, vmcpServer)
275+
Expect(err).ToNot(HaveOccurred())
276+
277+
Expect(vmcpServer.Spec.Aggregation).ToNot(BeNil())
278+
Expect(vmcpServer.Spec.Aggregation.Tools).To(HaveLen(1))
279+
280+
// Verify backend config has overrides
281+
backendConfig := vmcpServer.Spec.Aggregation.Tools[0]
282+
Expect(backendConfig.Workload).To(Equal(backendName))
283+
Expect(backendConfig.Overrides).To(HaveLen(1))
284+
285+
// Filter should contain the user-facing name (after override)
286+
Expect(backendConfig.Filter).To(ContainElement(renamedToolName),
287+
"Filter should contain the renamed tool name (user-facing name)")
288+
289+
override, exists := backendConfig.Overrides[originalToolName]
290+
Expect(exists).To(BeTrue(), "Should have override for original tool name")
291+
Expect(override.Name).To(Equal(renamedToolName))
292+
Expect(override.Description).To(Equal(newDescription))
293+
})
294+
})
295+
})

0 commit comments

Comments
 (0)