Skip to content

Commit 419d7c3

Browse files
JAORMXclaude
andauthored
Add vmcp e2e tests using yardstick MCP server (#2778)
* Add vmcp e2e tests using yardstick MCP server This adds two new e2e test files for VirtualMCPServer: 1. virtualmcp_yardstick_base_test.go: - Tests basic multi-backend aggregation using yardstick - Verifies prefix conflict resolution - Tests tool listing and tool calls through vMCP - Uses deterministic yardstick echo tool for reliable testing 2. virtualmcp_aggregation_filtering_test.go: - Tests tool filtering per backend workload - Verifies that filtered tools from one backend appear - Verifies that empty filter excludes all tools from a backend - Tests that filtered tools can still be called Yardstick is a deterministic MCP server designed for testing that provides an "echo" tool returning the input - perfect for verifying data flow through vMCP aggregation and composite tools. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix tool filtering test to use non-matching filter workaround The previous test incorrectly assumed that an empty filter `[]string{}` would exclude all tools from a backend. In reality, empty/nil filter means "allow all tools" (no filtering applied). This fix uses a non-matching filter `["nonexistent_tool"]` as a workaround to exclude all tools from backend2, since yardstick only exposes the "echo" tool. Added TODO comments referencing issue #2779 which proposes adding an `excludeAll` option for proper tool exclusion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 55ee37d commit 419d7c3

File tree

2 files changed

+742
-0
lines changed

2 files changed

+742
-0
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
package virtualmcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"time"
8+
9+
"github.com/mark3labs/mcp-go/client"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
corev1 "k8s.io/api/core/v1"
14+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+
"k8s.io/apimachinery/pkg/types"
16+
17+
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
18+
)
19+
20+
// Compile-time check to ensure corev1 is used (for Service type)
21+
var _ = corev1.ServiceSpec{}
22+
23+
var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() {
24+
var (
25+
testNamespace = "default"
26+
mcpGroupName = "test-filtering-group"
27+
vmcpServerName = "test-vmcp-filtering"
28+
backend1Name = "yardstick-filter-a"
29+
backend2Name = "yardstick-filter-b"
30+
timeout = 5 * time.Minute
31+
pollingInterval = 5 * time.Second
32+
vmcpNodePort int32
33+
)
34+
35+
vmcpServiceName := func() string {
36+
return fmt.Sprintf("vmcp-%s", vmcpServerName)
37+
}
38+
39+
BeforeAll(func() {
40+
By("Creating MCPGroup for filtering test")
41+
mcpGroup := &mcpv1alpha1.MCPGroup{
42+
ObjectMeta: metav1.ObjectMeta{
43+
Name: mcpGroupName,
44+
Namespace: testNamespace,
45+
},
46+
Spec: mcpv1alpha1.MCPGroupSpec{
47+
Description: "Test MCP Group for tool filtering E2E tests",
48+
},
49+
}
50+
Expect(k8sClient.Create(ctx, mcpGroup)).To(Succeed())
51+
52+
By("Waiting for MCPGroup to be ready")
53+
Eventually(func() bool {
54+
err := k8sClient.Get(ctx, types.NamespacedName{
55+
Name: mcpGroupName,
56+
Namespace: testNamespace,
57+
}, mcpGroup)
58+
if err != nil {
59+
return false
60+
}
61+
return mcpGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady
62+
}, timeout, pollingInterval).Should(BeTrue())
63+
64+
By("Creating first yardstick backend MCPServer")
65+
backend1 := &mcpv1alpha1.MCPServer{
66+
ObjectMeta: metav1.ObjectMeta{
67+
Name: backend1Name,
68+
Namespace: testNamespace,
69+
},
70+
Spec: mcpv1alpha1.MCPServerSpec{
71+
GroupRef: mcpGroupName,
72+
Image: YardstickImage,
73+
Transport: "streamable-http",
74+
ProxyPort: 8080,
75+
McpPort: 8080,
76+
Env: []mcpv1alpha1.EnvVar{
77+
{Name: "TRANSPORT", Value: "streamable-http"},
78+
},
79+
},
80+
}
81+
Expect(k8sClient.Create(ctx, backend1)).To(Succeed())
82+
83+
By("Creating second yardstick backend MCPServer")
84+
backend2 := &mcpv1alpha1.MCPServer{
85+
ObjectMeta: metav1.ObjectMeta{
86+
Name: backend2Name,
87+
Namespace: testNamespace,
88+
},
89+
Spec: mcpv1alpha1.MCPServerSpec{
90+
GroupRef: mcpGroupName,
91+
Image: YardstickImage,
92+
Transport: "streamable-http",
93+
ProxyPort: 8080,
94+
McpPort: 8080,
95+
Env: []mcpv1alpha1.EnvVar{
96+
{Name: "TRANSPORT", Value: "streamable-http"},
97+
},
98+
},
99+
}
100+
Expect(k8sClient.Create(ctx, backend2)).To(Succeed())
101+
102+
By("Waiting for backend MCPServers to be ready")
103+
for _, backendName := range []string{backend1Name, backend2Name} {
104+
Eventually(func() error {
105+
server := &mcpv1alpha1.MCPServer{}
106+
err := k8sClient.Get(ctx, types.NamespacedName{
107+
Name: backendName,
108+
Namespace: testNamespace,
109+
}, server)
110+
if err != nil {
111+
return fmt.Errorf("failed to get server: %w", err)
112+
}
113+
if server.Status.Phase == mcpv1alpha1.MCPServerPhaseRunning {
114+
return nil
115+
}
116+
return fmt.Errorf("%s not ready yet, phase: %s", backendName, server.Status.Phase)
117+
}, timeout, pollingInterval).Should(Succeed(), fmt.Sprintf("%s should be ready", backendName))
118+
}
119+
120+
By("Creating VirtualMCPServer with tool filtering - only expose tools from backend1")
121+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
122+
ObjectMeta: metav1.ObjectMeta{
123+
Name: vmcpServerName,
124+
Namespace: testNamespace,
125+
},
126+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
127+
GroupRef: mcpv1alpha1.GroupRef{
128+
Name: mcpGroupName,
129+
},
130+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
131+
Type: "anonymous",
132+
},
133+
Aggregation: &mcpv1alpha1.AggregationConfig{
134+
ConflictResolution: "prefix",
135+
// Tool filtering: only allow echo from backend1, nothing from backend2
136+
// TODO(#2779): Currently there's no way to exclude all tools from a backend.
137+
// Using a non-matching filter as a workaround until excludeAll is implemented.
138+
// See: https://github.com/stacklok/toolhive/issues/2779
139+
Tools: []mcpv1alpha1.WorkloadToolConfig{
140+
{
141+
Workload: backend1Name,
142+
Filter: []string{"echo"}, // Only expose echo tool
143+
},
144+
{
145+
Workload: backend2Name,
146+
Filter: []string{"nonexistent_tool"}, // Filter out all tools (workaround)
147+
},
148+
},
149+
},
150+
ServiceType: "NodePort",
151+
},
152+
}
153+
Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed())
154+
155+
By("Waiting for VirtualMCPServer to be ready")
156+
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout)
157+
158+
By("Getting NodePort for VirtualMCPServer")
159+
Eventually(func() error {
160+
service := &corev1.Service{}
161+
serviceName := vmcpServiceName()
162+
err := k8sClient.Get(ctx, types.NamespacedName{
163+
Name: serviceName,
164+
Namespace: testNamespace,
165+
}, service)
166+
if err != nil {
167+
return err
168+
}
169+
if len(service.Spec.Ports) == 0 || service.Spec.Ports[0].NodePort == 0 {
170+
return fmt.Errorf("nodePort not assigned for vmcp")
171+
}
172+
vmcpNodePort = service.Spec.Ports[0].NodePort
173+
return nil
174+
}, timeout, pollingInterval).Should(Succeed())
175+
176+
By(fmt.Sprintf("VirtualMCPServer accessible at http://localhost:%d", vmcpNodePort))
177+
})
178+
179+
AfterAll(func() {
180+
By("Cleaning up VirtualMCPServer")
181+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{
182+
ObjectMeta: metav1.ObjectMeta{
183+
Name: vmcpServerName,
184+
Namespace: testNamespace,
185+
},
186+
}
187+
_ = k8sClient.Delete(ctx, vmcpServer)
188+
189+
By("Cleaning up backend MCPServers")
190+
for _, backendName := range []string{backend1Name, backend2Name} {
191+
backend := &mcpv1alpha1.MCPServer{
192+
ObjectMeta: metav1.ObjectMeta{
193+
Name: backendName,
194+
Namespace: testNamespace,
195+
},
196+
}
197+
_ = k8sClient.Delete(ctx, backend)
198+
}
199+
200+
By("Cleaning up MCPGroup")
201+
mcpGroup := &mcpv1alpha1.MCPGroup{
202+
ObjectMeta: metav1.ObjectMeta{
203+
Name: mcpGroupName,
204+
Namespace: testNamespace,
205+
},
206+
}
207+
_ = k8sClient.Delete(ctx, mcpGroup)
208+
})
209+
210+
Context("when tool filtering is configured", func() {
211+
It("should only expose filtered tools from backend1", func() {
212+
By("Creating MCP client for VirtualMCPServer")
213+
serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort)
214+
mcpClient, err := client.NewStreamableHttpClient(serverURL)
215+
Expect(err).ToNot(HaveOccurred())
216+
defer mcpClient.Close()
217+
218+
By("Starting transport and initializing connection")
219+
testCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
220+
defer cancel()
221+
222+
err = mcpClient.Start(testCtx)
223+
Expect(err).ToNot(HaveOccurred())
224+
225+
initRequest := mcp.InitializeRequest{}
226+
initRequest.Params.ProtocolVersion = mcpProtocolVersion
227+
initRequest.Params.ClientInfo = mcp.Implementation{
228+
Name: "toolhive-filtering-test",
229+
Version: "1.0.0",
230+
}
231+
232+
_, err = mcpClient.Initialize(testCtx, initRequest)
233+
Expect(err).ToNot(HaveOccurred())
234+
235+
By("Listing tools from VirtualMCPServer")
236+
listRequest := mcp.ListToolsRequest{}
237+
tools, err := mcpClient.ListTools(testCtx, listRequest)
238+
Expect(err).ToNot(HaveOccurred())
239+
240+
By(fmt.Sprintf("VirtualMCPServer exposes %d tools after filtering", len(tools.Tools)))
241+
for _, tool := range tools.Tools {
242+
GinkgoWriter.Printf(" Exposed tool: %s - %s\n", tool.Name, tool.Description)
243+
}
244+
245+
// Verify filtering: should only have echo tool from backend1
246+
toolNames := make([]string, len(tools.Tools))
247+
for i, tool := range tools.Tools {
248+
toolNames[i] = tool.Name
249+
}
250+
251+
// Should have tool from backend1
252+
hasBackend1Tool := false
253+
for _, name := range toolNames {
254+
if strings.Contains(name, backend1Name) && strings.Contains(name, "echo") {
255+
hasBackend1Tool = true
256+
}
257+
}
258+
Expect(hasBackend1Tool).To(BeTrue(), "Should have echo tool from backend1")
259+
260+
// Should NOT have any tool from backend2 (filtered with non-matching filter)
261+
// TODO(#2779): Once excludeAll is implemented, update this test to use it
262+
hasBackend2Tool := false
263+
for _, name := range toolNames {
264+
if strings.Contains(name, backend2Name) {
265+
hasBackend2Tool = true
266+
}
267+
}
268+
Expect(hasBackend2Tool).To(BeFalse(), "Should NOT have any tool from backend2 (filtered out via non-matching filter)")
269+
})
270+
271+
It("should still allow calling filtered tools", func() {
272+
By("Creating MCP client for VirtualMCPServer")
273+
serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort)
274+
mcpClient, err := client.NewStreamableHttpClient(serverURL)
275+
Expect(err).ToNot(HaveOccurred())
276+
defer mcpClient.Close()
277+
278+
By("Starting transport and initializing connection")
279+
testCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
280+
defer cancel()
281+
282+
err = mcpClient.Start(testCtx)
283+
Expect(err).ToNot(HaveOccurred())
284+
285+
initRequest := mcp.InitializeRequest{}
286+
initRequest.Params.ProtocolVersion = mcpProtocolVersion
287+
initRequest.Params.ClientInfo = mcp.Implementation{
288+
Name: "toolhive-filtering-test",
289+
Version: "1.0.0",
290+
}
291+
292+
_, err = mcpClient.Initialize(testCtx, initRequest)
293+
Expect(err).ToNot(HaveOccurred())
294+
295+
By("Listing available tools")
296+
listRequest := mcp.ListToolsRequest{}
297+
tools, err := mcpClient.ListTools(testCtx, listRequest)
298+
Expect(err).ToNot(HaveOccurred())
299+
300+
// Find the backend1 echo tool
301+
var targetToolName string
302+
for _, tool := range tools.Tools {
303+
if strings.Contains(tool.Name, backend1Name) && strings.Contains(tool.Name, "echo") {
304+
targetToolName = tool.Name
305+
break
306+
}
307+
}
308+
Expect(targetToolName).ToNot(BeEmpty(), "Should find echo tool from backend1")
309+
310+
By(fmt.Sprintf("Calling filtered echo tool: %s", targetToolName))
311+
toolCallCtx, toolCallCancel := context.WithTimeout(context.Background(), 30*time.Second)
312+
defer toolCallCancel()
313+
314+
testInput := "filtered123"
315+
callRequest := mcp.CallToolRequest{}
316+
callRequest.Params.Name = targetToolName
317+
callRequest.Params.Arguments = map[string]any{
318+
"input": testInput,
319+
}
320+
321+
result, err := mcpClient.CallTool(toolCallCtx, callRequest)
322+
Expect(err).ToNot(HaveOccurred(), "Should be able to call filtered tool")
323+
Expect(result).ToNot(BeNil())
324+
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
325+
326+
GinkgoWriter.Printf("Filtered tool call successful: %s\n", targetToolName)
327+
})
328+
})
329+
330+
Context("when verifying filtering configuration", func() {
331+
It("should have correct aggregation configuration with tool filters", func() {
332+
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
333+
err := k8sClient.Get(ctx, types.NamespacedName{
334+
Name: vmcpServerName,
335+
Namespace: testNamespace,
336+
}, vmcpServer)
337+
Expect(err).ToNot(HaveOccurred())
338+
339+
Expect(vmcpServer.Spec.Aggregation).ToNot(BeNil())
340+
Expect(vmcpServer.Spec.Aggregation.Tools).To(HaveLen(2))
341+
342+
// Verify backend1 filter allows echo
343+
var backend1Config *mcpv1alpha1.WorkloadToolConfig
344+
var backend2Config *mcpv1alpha1.WorkloadToolConfig
345+
for i := range vmcpServer.Spec.Aggregation.Tools {
346+
if vmcpServer.Spec.Aggregation.Tools[i].Workload == backend1Name {
347+
backend1Config = &vmcpServer.Spec.Aggregation.Tools[i]
348+
}
349+
if vmcpServer.Spec.Aggregation.Tools[i].Workload == backend2Name {
350+
backend2Config = &vmcpServer.Spec.Aggregation.Tools[i]
351+
}
352+
}
353+
354+
Expect(backend1Config).ToNot(BeNil())
355+
Expect(backend1Config.Filter).To(ContainElement("echo"))
356+
357+
Expect(backend2Config).ToNot(BeNil())
358+
// TODO(#2779): Once excludeAll is implemented, update this to use excludeAll: true
359+
Expect(backend2Config.Filter).To(ContainElement("nonexistent_tool"), "Backend2 should have non-matching filter as workaround")
360+
})
361+
})
362+
})

0 commit comments

Comments
 (0)