Skip to content

Commit a5b59c8

Browse files
yroblataskbot
andauthored
Consolidate and fix VirtualMCP E2E tests (#2978)
* Fix VirtualMCP e2e test port accessibility checks Enhance test helpers to verify MCP server is actually listening before attempting client connections, preventing EOF errors. The original helpers only checked Kubernetes resource status (Ready condition and NodePort assignment) but didn't verify the actual pods were running or the port was accessible. This timing gap caused tests to fail with EOF errors when connecting to localhost:30020. * fix ci --------- Co-authored-by: taskbot <[email protected]>
1 parent 242d3ae commit a5b59c8

13 files changed

+387
-1059
lines changed

test/e2e/thv-operator/virtualmcp/helpers.go

Lines changed: 181 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1+
// Package virtualmcp provides helper functions for VirtualMCP E2E tests.
12
package virtualmcp
23

34
import (
45
"bytes"
56
"context"
67
"fmt"
78
"io"
9+
"net"
10+
"net/http"
811
"os"
12+
"strings"
913
"time"
1014

1115
mcpclient "github.com/mark3labs/mcp-go/client"
1216
"github.com/mark3labs/mcp-go/mcp"
17+
"github.com/onsi/ginkgo/v2"
1318
"github.com/onsi/gomega"
1419
appsv1 "k8s.io/api/apps/v1"
1520
corev1 "k8s.io/api/core/v1"
@@ -25,7 +30,14 @@ import (
2530
)
2631

2732
// WaitForVirtualMCPServerReady waits for a VirtualMCPServer to reach Ready status
28-
func WaitForVirtualMCPServerReady(ctx context.Context, c client.Client, name, namespace string, timeout time.Duration) {
33+
// and ensures the associated pods are actually running and ready
34+
func WaitForVirtualMCPServerReady(
35+
ctx context.Context,
36+
c client.Client,
37+
name, namespace string,
38+
timeout time.Duration,
39+
pollingInterval time.Duration,
40+
) {
2941
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
3042

3143
gomega.Eventually(func() error {
@@ -39,13 +51,63 @@ func WaitForVirtualMCPServerReady(ctx context.Context, c client.Client, name, na
3951
for _, condition := range vmcpServer.Status.Conditions {
4052
if condition.Type == "Ready" {
4153
if condition.Status == "True" {
54+
// Also check that the pods are actually running and ready
55+
labels := map[string]string{
56+
"app.kubernetes.io/name": "virtualmcpserver",
57+
"app.kubernetes.io/instance": name,
58+
}
59+
if err := checkPodsReady(ctx, c, namespace, labels); err != nil {
60+
return fmt.Errorf("VirtualMCPServer ready but pods not ready: %w", err)
61+
}
4262
return nil
4363
}
4464
return fmt.Errorf("ready condition is %s: %s", condition.Status, condition.Message)
4565
}
4666
}
4767
return fmt.Errorf("ready condition not found")
48-
}, timeout, 5*time.Second).Should(gomega.Succeed())
68+
}, timeout, pollingInterval).Should(gomega.Succeed())
69+
}
70+
71+
// checkPodsReady checks if all pods matching the given labels are ready
72+
func checkPodsReady(ctx context.Context, c client.Client, namespace string, labels map[string]string) error {
73+
podList := &corev1.PodList{}
74+
if err := c.List(ctx, podList,
75+
client.InNamespace(namespace),
76+
client.MatchingLabels(labels)); err != nil {
77+
return fmt.Errorf("failed to list pods: %w", err)
78+
}
79+
80+
if len(podList.Items) == 0 {
81+
return fmt.Errorf("no pods found with labels %v", labels)
82+
}
83+
84+
for _, pod := range podList.Items {
85+
if pod.Status.Phase != corev1.PodRunning {
86+
return fmt.Errorf("pod %s is in phase %s", pod.Name, pod.Status.Phase)
87+
}
88+
89+
containerReady := false
90+
podReady := false
91+
92+
for _, condition := range pod.Status.Conditions {
93+
if condition.Type == corev1.ContainersReady {
94+
containerReady = condition.Status == corev1.ConditionTrue
95+
}
96+
97+
if condition.Type == corev1.PodReady {
98+
podReady = condition.Status == corev1.ConditionTrue
99+
}
100+
}
101+
102+
if !containerReady {
103+
return fmt.Errorf("pod %s containers not ready", pod.Name)
104+
}
105+
106+
if !podReady {
107+
return fmt.Errorf("pod %s not ready", pod.Name)
108+
}
109+
}
110+
return nil
49111
}
50112

51113
// InitializedMCPClient holds an initialized MCP client with its associated context
@@ -163,47 +225,17 @@ func GetVirtualMCPServerPods(ctx context.Context, c client.Client, vmcpServerNam
163225
}
164226

165227
// WaitForPodsReady waits for all pods matching labels to be ready
166-
func WaitForPodsReady(ctx context.Context, c client.Client, namespace string, labels map[string]string, timeout time.Duration) {
228+
func WaitForPodsReady(
229+
ctx context.Context,
230+
c client.Client,
231+
namespace string,
232+
labels map[string]string,
233+
timeout time.Duration,
234+
pollingInterval time.Duration,
235+
) {
167236
gomega.Eventually(func() error {
168-
podList := &corev1.PodList{}
169-
if err := c.List(ctx, podList,
170-
client.InNamespace(namespace),
171-
client.MatchingLabels(labels)); err != nil {
172-
return err
173-
}
174-
175-
if len(podList.Items) == 0 {
176-
return fmt.Errorf("no pods found with labels %v", labels)
177-
}
178-
179-
for _, pod := range podList.Items {
180-
if pod.Status.Phase != corev1.PodRunning {
181-
return fmt.Errorf("pod %s is in phase %s", pod.Name, pod.Status.Phase)
182-
}
183-
184-
containerReady := false
185-
podReady := false
186-
187-
for _, condition := range pod.Status.Conditions {
188-
if condition.Type == corev1.ContainersReady {
189-
containerReady = condition.Status == corev1.ConditionTrue
190-
}
191-
192-
if condition.Type == corev1.PodReady {
193-
podReady = condition.Status == corev1.ConditionTrue
194-
}
195-
}
196-
197-
if !containerReady {
198-
return fmt.Errorf("pod %s containers not ready", pod.Name)
199-
}
200-
201-
if !podReady {
202-
return fmt.Errorf("pod %s not ready", pod.Name)
203-
}
204-
}
205-
return nil
206-
}, timeout, 5*time.Second).Should(gomega.Succeed())
237+
return checkPodsReady(ctx, c, namespace, labels)
238+
}, timeout, pollingInterval).Should(gomega.Succeed())
207239
}
208240

209241
// GetMCPGroupBackends returns the list of backend MCPServers in an MCPGroup
@@ -269,6 +301,7 @@ func WaitForCondition(
269301
conditionType string,
270302
expectedStatus string,
271303
timeout time.Duration,
304+
pollingInterval time.Duration,
272305
) {
273306
gomega.Eventually(func() error {
274307
vmcpServer := &mcpv1alpha1.VirtualMCPServer{}
@@ -284,7 +317,7 @@ func WaitForCondition(
284317
}
285318

286319
return fmt.Errorf("condition %s not found with status %s", conditionType, expectedStatus)
287-
}, timeout, 5*time.Second).Should(gomega.Succeed())
320+
}, timeout, pollingInterval).Should(gomega.Succeed())
288321
}
289322

290323
// OIDC Testing Helpers
@@ -680,7 +713,8 @@ func CreateMCPServerAndWait(
680713
return backend
681714
}
682715

683-
// GetVMCPNodePort waits for the VirtualMCPServer service to have a NodePort assigned and returns it.
716+
// GetVMCPNodePort waits for the VirtualMCPServer service to have a NodePort assigned
717+
// and verifies the port is accessible.
684718
func GetVMCPNodePort(
685719
ctx context.Context,
686720
c client.Client,
@@ -703,12 +737,114 @@ func GetVMCPNodePort(
703737
return fmt.Errorf("nodePort not assigned for vmcp service %s", serviceName)
704738
}
705739
nodePort = service.Spec.Ports[0].NodePort
740+
741+
// Verify the TCP port is accessible
742+
if err := checkPortAccessible(nodePort, 1*time.Second); err != nil {
743+
return fmt.Errorf("nodePort %d assigned but not accessible: %w", nodePort, err)
744+
}
745+
746+
// Verify the HTTP server is ready to handle requests
747+
if err := checkHTTPHealthReady(nodePort, 2*time.Second); err != nil {
748+
return fmt.Errorf("nodePort %d accessible but HTTP server not ready: %w", nodePort, err)
749+
}
750+
706751
return nil
707-
}, timeout, pollingInterval).Should(gomega.Succeed(), "NodePort should be assigned")
752+
}, timeout, pollingInterval).Should(gomega.Succeed(), "NodePort should be assigned and HTTP server ready")
708753

709754
return nodePort
710755
}
711756

757+
// checkPortAccessible verifies that the port is open and accepting TCP connections.
758+
// This is a lightweight check that completes in milliseconds instead of the seconds
759+
// required for a full MCP session initialization.
760+
func checkPortAccessible(nodePort int32, timeout time.Duration) error {
761+
address := fmt.Sprintf("localhost:%d", nodePort)
762+
conn, err := net.DialTimeout("tcp", address, timeout)
763+
if err != nil {
764+
return fmt.Errorf("port %d not accessible: %w", nodePort, err)
765+
}
766+
// Port is accessible - close connection (ignore errors as port accessibility is confirmed)
767+
_ = conn.Close()
768+
return nil
769+
}
770+
771+
// checkHTTPHealthReady verifies the HTTP server is ready by checking the /health endpoint.
772+
// This is more reliable than just TCP check as it ensures the application is serving requests.
773+
func checkHTTPHealthReady(nodePort int32, timeout time.Duration) error {
774+
httpClient := &http.Client{Timeout: timeout}
775+
url := fmt.Sprintf("http://localhost:%d/health", nodePort)
776+
777+
resp, err := httpClient.Get(url)
778+
if err != nil {
779+
return fmt.Errorf("health check failed for port %d: %w", nodePort, err)
780+
}
781+
defer resp.Body.Close()
782+
783+
if resp.StatusCode != http.StatusOK {
784+
return fmt.Errorf("health check returned status %d for port %d", resp.StatusCode, nodePort)
785+
}
786+
787+
return nil
788+
}
789+
790+
// TestToolListingAndCall is a shared helper that creates an MCP client, lists tools,
791+
// finds a tool matching the pattern, calls it, and verifies the response.
792+
// This eliminates the duplicate "create client → list → call" pattern found in most tests.
793+
func TestToolListingAndCall(vmcpNodePort int32, clientName string, toolNamePattern string, testInput string) {
794+
ginkgo.By("Creating and initializing MCP client")
795+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, clientName, 30*time.Second)
796+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
797+
defer mcpClient.Close()
798+
799+
ginkgo.By("Listing tools from VirtualMCPServer")
800+
listRequest := mcp.ListToolsRequest{}
801+
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
802+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
803+
gomega.Expect(tools.Tools).ToNot(gomega.BeEmpty())
804+
805+
// Find a tool matching the pattern
806+
var targetToolName string
807+
for _, tool := range tools.Tools {
808+
if strings.Contains(tool.Name, toolNamePattern) {
809+
targetToolName = tool.Name
810+
break
811+
}
812+
}
813+
gomega.Expect(targetToolName).ToNot(gomega.BeEmpty(), fmt.Sprintf("Should find a tool matching pattern: %s", toolNamePattern))
814+
815+
ginkgo.By(fmt.Sprintf("Calling tool: %s", targetToolName))
816+
callRequest := mcp.CallToolRequest{}
817+
callRequest.Params.Name = targetToolName
818+
callRequest.Params.Arguments = map[string]any{
819+
"input": testInput,
820+
}
821+
822+
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
823+
gomega.Expect(err).ToNot(gomega.HaveOccurred(), "Should successfully call tool")
824+
gomega.Expect(result).ToNot(gomega.BeNil())
825+
gomega.Expect(result.Content).ToNot(gomega.BeEmpty(), "Should have content in response")
826+
827+
fmt.Fprintf(ginkgo.GinkgoWriter, "✓ Successfully called tool %s\n", targetToolName)
828+
}
829+
830+
// TestToolListing is a shared helper that creates an MCP client and lists tools.
831+
// Returns the list of tools for further assertions.
832+
func TestToolListing(vmcpNodePort int32, clientName string) []mcp.Tool {
833+
ginkgo.By("Creating and initializing MCP client")
834+
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, clientName, 30*time.Second)
835+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
836+
defer mcpClient.Close()
837+
838+
ginkgo.By("Listing tools from VirtualMCPServer")
839+
listRequest := mcp.ListToolsRequest{}
840+
toolsResult, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
841+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
842+
gomega.Expect(toolsResult.Tools).ToNot(gomega.BeEmpty())
843+
844+
fmt.Fprintf(ginkgo.GinkgoWriter, "Listed %d tools from VirtualMCPServer\n", len(toolsResult.Tools))
845+
return toolsResult.Tools
846+
}
847+
712848
// InstrumentedBackendScript is an instrumented backend script that tracks Bearer tokens
713849
const InstrumentedBackendScript = `
714850
pip install --quiet flask && python3 - <<'PYTHON_SCRIPT'

test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() {
7676
Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed())
7777

7878
By("Waiting for VirtualMCPServer to be ready")
79-
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout)
79+
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
8080

8181
By("Getting NodePort for VirtualMCPServer")
8282
vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
@@ -159,40 +159,8 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() {
159159
})
160160

161161
It("should still allow calling filtered tools", func() {
162-
By("Creating and initializing MCP client for VirtualMCPServer")
163-
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-filtering-test", 30*time.Second)
164-
Expect(err).ToNot(HaveOccurred())
165-
defer mcpClient.Close()
166-
167-
By("Listing available tools")
168-
listRequest := mcp.ListToolsRequest{}
169-
tools, err := mcpClient.Client.ListTools(mcpClient.Ctx, listRequest)
170-
Expect(err).ToNot(HaveOccurred())
171-
172-
// Find the backend1 echo tool
173-
var targetToolName string
174-
for _, tool := range tools.Tools {
175-
if strings.Contains(tool.Name, backend1Name) && strings.Contains(tool.Name, "echo") {
176-
targetToolName = tool.Name
177-
break
178-
}
179-
}
180-
Expect(targetToolName).ToNot(BeEmpty(), "Should find echo tool from backend1")
181-
182-
By(fmt.Sprintf("Calling filtered echo tool: %s", targetToolName))
183-
testInput := "filtered123"
184-
callRequest := mcp.CallToolRequest{}
185-
callRequest.Params.Name = targetToolName
186-
callRequest.Params.Arguments = map[string]any{
187-
"input": testInput,
188-
}
189-
190-
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
191-
Expect(err).ToNot(HaveOccurred(), "Should be able to call filtered tool")
192-
Expect(result).ToNot(BeNil())
193-
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
194-
195-
GinkgoWriter.Printf("Filtered tool call successful: %s\n", targetToolName)
162+
// Use shared helper to test tool listing and calling
163+
TestToolListingAndCall(vmcpNodePort, "toolhive-filtering-test", "echo", "filtered123")
196164
})
197165
})
198166

test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() {
7676
Expect(k8sClient.Create(ctx, vmcpServer)).To(Succeed())
7777

7878
By("Waiting for VirtualMCPServer to be ready")
79-
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout)
79+
WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
8080

8181
By("Getting NodePort for VirtualMCPServer")
8282
vmcpNodePort = GetVMCPNodePort(ctx, k8sClient, vmcpServerName, testNamespace, timeout, pollingInterval)
@@ -166,28 +166,8 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() {
166166
})
167167

168168
It("should allow calling the renamed tool", func() {
169-
By("Creating and initializing MCP client for VirtualMCPServer")
170-
mcpClient, err := CreateInitializedMCPClient(vmcpNodePort, "toolhive-overrides-test", 30*time.Second)
171-
Expect(err).ToNot(HaveOccurred())
172-
defer mcpClient.Close()
173-
174-
renamedToolFullName := fmt.Sprintf("%s_%s", backendName, renamedToolName)
175-
By(fmt.Sprintf("Calling renamed tool: %s", renamedToolFullName))
176-
177-
testInput := "override_test_123"
178-
callRequest := mcp.CallToolRequest{}
179-
callRequest.Params.Name = renamedToolFullName
180-
callRequest.Params.Arguments = map[string]any{
181-
"input": testInput,
182-
}
183-
184-
result, err := mcpClient.Client.CallTool(mcpClient.Ctx, callRequest)
185-
Expect(err).ToNot(HaveOccurred(), "Should be able to call renamed tool")
186-
Expect(result).ToNot(BeNil())
187-
Expect(result.Content).ToNot(BeEmpty(), "Should have content in response")
188-
189-
// Yardstick echo tool echoes back the input
190-
GinkgoWriter.Printf("Renamed tool call result: %+v\n", result.Content)
169+
// Use shared helper to test tool listing and calling
170+
TestToolListingAndCall(vmcpNodePort, "toolhive-overrides-test", renamedToolName, "override_test_123")
191171
})
192172
})
193173

0 commit comments

Comments
 (0)