1+ // Package virtualmcp provides helper functions for VirtualMCP E2E tests.
12package virtualmcp
23
34import (
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.
684718func 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
713849const InstrumentedBackendScript = `
714850pip install --quiet flask && python3 - <<'PYTHON_SCRIPT'
0 commit comments