@@ -23,7 +23,13 @@ import (
2323// Note: make sure you ran `task install` before running this test
2424// ------------------------------------------------------------------------------------------------
2525
26- func TestServer (t * testing.T ) {
26+ // TestServer verifies basic MCP functionality with both stdio and http transports.
27+ // Both transports run in stateful mode (ListChanged enabled) and test:
28+ // - Tool calls (unhealthyApplications, unhealthyApplicationResources)
29+ // - Error handling (argocd-error, unreachable scenarios)
30+ // - Metrics collection (for http transport)
31+ // - Session reuse across multiple tool calls
32+ func TestStatefulServer (t * testing.T ) {
2733
2834 testdata := []struct {
2935 name string
@@ -35,11 +41,16 @@ func TestServer(t *testing.T) {
3541 },
3642 {
3743 name : "http" ,
38- init : newHTTPSession ("http://localhost:50081/mcp" ),
44+ init : func (t * testing.T ) * mcp.ClientSession {
45+ ctx := context .Background ()
46+ session , err := newHTTPSession (ctx , "http://localhost:50081/mcp" , "e2e-test-client" )
47+ require .NoError (t , err )
48+ return session
49+ },
3950 },
4051 }
4152
42- // test stdio and http transports with a valid Argo CD client
53+ // Test stdio and http transports with a valid Argo CD client (stateful mode)
4354 for _ , td := range testdata {
4455 t .Run (td .name , func (t * testing.T ) {
4556 // given
@@ -212,6 +223,11 @@ func TestServer(t *testing.T) {
212223 assert .Equal (t , mcpCallsDurationSecondsInfBucketBefore + 1 , mcpCallsDurationSecondsInfBucketAfter )
213224 }
214225 })
226+
227+ t .Run ("verify/capabilities/listChanged" , func (t * testing.T ) {
228+ // Both stdio and http transports use stateful mode by default
229+ assertListChanged (t , session , true )
230+ })
215231 })
216232 }
217233
@@ -225,7 +241,12 @@ func TestServer(t *testing.T) {
225241 },
226242 {
227243 name : "http-unreachable" ,
228- init : newHTTPSession ("http://localhost:50082/mcp" ), // invalid URL and token for the Argo CD server
244+ init : func (t * testing.T ) * mcp.ClientSession {
245+ ctx := context .Background ()
246+ session , err := newHTTPSession (ctx , "http://localhost:50082/mcp" , "e2e-test-client" )
247+ require .NoError (t , err )
248+ return session
249+ }, // invalid URL and token for the Argo CD server
229250 },
230251 }
231252
@@ -250,6 +271,55 @@ func TestServer(t *testing.T) {
250271 }
251272}
252273
274+ // TestStateless verifies that multiple stateless server instances work correctly
275+ // with load balancing across replicas. This comprehensive test validates:
276+ // - Initialize response and capabilities with no ListChanged notifications
277+ // - Session reuse across multiple requests (list tools and call tools)
278+ // - Tools functionality with content validation
279+ func TestStatelessServer (t * testing.T ) {
280+ ctx := context .Background ()
281+ serverURL := "http://localhost:50090/mcp"
282+
283+ // Initialize a single session for the entire test
284+ session , err := newHTTPSession (ctx , serverURL , "e2e-test-stateless" )
285+ require .NoError (t , err )
286+ defer session .Close ()
287+
288+ // Step 1: Validate initialize response for stateless mode
289+ assertInitializeResponse (t , session , true )
290+
291+ // Step 2: Verify ListChanged is false (no notifications available in stateless mode)
292+ assertListChanged (t , session , false )
293+
294+ // Step 3: Verify session can be reused by listing tools multiple times
295+ for i := 0 ; i < 5 ; i ++ {
296+ tools , listErr := session .ListTools (ctx , & mcp.ListToolsParams {})
297+ require .NoError (t , listErr , "should list tools on request %d" , i )
298+ assert .NotEmpty (t , tools .Tools , "should have tools on request %d" , i )
299+ }
300+
301+ // Step 4: Verify tools work correctly with content validation
302+ result , callErr := session .CallTool (ctx , & mcp.CallToolParams {
303+ Name : "unhealthyApplications" ,
304+ })
305+ require .NoError (t , callErr )
306+ require .False (t , result .IsError , "tool call should succeed" )
307+ assert .NotEmpty (t , result .Content , "tool should return content" )
308+
309+ // Verify the content is correct
310+ expectedContent := map [string ]any {
311+ "degraded" : []any {"a-degraded-application" , "another-degraded-application" },
312+ "progressing" : []any {"a-progressing-application" , "another-progressing-application" },
313+ "outOfSync" : []any {"an-out-of-sync-application" , "another-out-of-sync-application" },
314+ }
315+ expectedContentText , marshalErr := json .Marshal (expectedContent )
316+ require .NoError (t , marshalErr )
317+
318+ resultContent , ok := result .Content [0 ].(* mcp.TextContent )
319+ require .True (t , ok )
320+ assert .JSONEq (t , string (expectedContentText ), resultContent .Text )
321+ }
322+
253323func getMetrics (t * testing.T , mcpServerURL string , labels map [string ]string ) (int64 , int64 ) { //nolint:unparam
254324 labelStrings := make ([]string , 0 , 2 * len (labels ))
255325 for k , v := range labels {
@@ -287,19 +357,6 @@ func newStdioSession(mcpServerDebug bool, argocdURL string, argocdToken string,
287357 }
288358}
289359
290- func newHTTPSession (mcpServerURL string ) func (* testing.T ) * mcp.ClientSession {
291- return func (t * testing.T ) * mcp.ClientSession {
292- ctx := context .Background ()
293- cl := mcp .NewClient (& mcp.Implementation {Name : "e2e-test-client" , Version : "v1.0.0" }, nil )
294- session , err := cl .Connect (ctx , & mcp.StreamableClientTransport {
295- MaxRetries : 5 ,
296- Endpoint : mcpServerURL ,
297- }, nil )
298- require .NoError (t , err )
299- return session
300- }
301- }
302-
303360func newStdioServerCmd (ctx context.Context , mcpServerDebug bool , argocdURL string , argocdToken string , argocdInsecureURL bool ) * exec.Cmd {
304361 return exec .CommandContext (ctx , //nolint:gosec
305362 "argocd-mcp-server" ,
@@ -310,3 +367,68 @@ func newStdioServerCmd(ctx context.Context, mcpServerDebug bool, argocdURL strin
310367 "--insecure" , strconv .FormatBool (argocdInsecureURL ),
311368 )
312369}
370+
371+ func newHTTPSession (ctx context.Context , endpoint , clientName string ) (* mcp.ClientSession , error ) {
372+ client := mcp .NewClient (& mcp.Implementation {
373+ Name : clientName ,
374+ Version : "1.0.0" ,
375+ }, nil )
376+ return client .Connect (ctx , & mcp.StreamableClientTransport {
377+ MaxRetries : 5 ,
378+ Endpoint : endpoint ,
379+ }, nil )
380+ }
381+
382+ func assertListChanged (t * testing.T , session * mcp.ClientSession , expected bool ) {
383+ t .Helper ()
384+ initResult := session .InitializeResult ()
385+ require .NotNil (t , initResult , "should have initialize result" )
386+ require .NotNil (t , initResult .Capabilities , "should have capabilities" )
387+
388+ if initResult .Capabilities .Tools != nil {
389+ assert .Equal (t , expected , initResult .Capabilities .Tools .ListChanged ,
390+ "Tools.ListChanged should be %t" , expected )
391+ }
392+ if initResult .Capabilities .Prompts != nil {
393+ assert .Equal (t , expected , initResult .Capabilities .Prompts .ListChanged ,
394+ "Prompts.ListChanged should be %t" , expected )
395+ }
396+ }
397+
398+ // assertInitializeResponse performs comprehensive validation of the initialize response
399+ func assertInitializeResponse (t * testing.T , session * mcp.ClientSession , stateless bool ) {
400+ t .Helper ()
401+
402+ initResult := session .InitializeResult ()
403+ require .NotNil (t , initResult , "should have initialize result" )
404+
405+ // Verify server info exists
406+ require .NotNil (t , initResult .ServerInfo , "should have server info" )
407+ assert .NotEmpty (t , initResult .ServerInfo .Name , "server name should not be empty" )
408+ assert .NotEmpty (t , initResult .ServerInfo .Version , "server version should not be empty" )
409+
410+ // Verify protocol version exists
411+ assert .NotEmpty (t , initResult .ProtocolVersion , "protocol version should not be empty" )
412+
413+ // Verify capabilities
414+ require .NotNil (t , initResult .Capabilities , "should have capabilities" )
415+
416+ // In stateless mode: ListChanged should be false (no notifications)
417+ // In stateful mode: ListChanged should be true (notifications enabled)
418+
419+ // Tools capability
420+ require .NotNil (t , initResult .Capabilities .Tools , "should have tools capability" )
421+ if stateless {
422+ assert .False (t , initResult .Capabilities .Tools .ListChanged , "stateless mode should have Tools.ListChanged=false" )
423+ } else {
424+ assert .True (t , initResult .Capabilities .Tools .ListChanged , "stateful mode should have Tools.ListChanged=true" )
425+ }
426+
427+ // Prompts capability
428+ require .NotNil (t , initResult .Capabilities .Prompts , "should have prompts capability" )
429+ if stateless {
430+ assert .False (t , initResult .Capabilities .Prompts .ListChanged , "stateless mode should have Prompts.ListChanged=false" )
431+ } else {
432+ assert .True (t , initResult .Capabilities .Prompts .ListChanged , "stateful mode should have Prompts.ListChanged=true" )
433+ }
434+ }
0 commit comments