@@ -26,6 +26,7 @@ import (
2626 "encoding/json"
2727 "encoding/pem"
2828 "fmt"
29+ "io"
2930 "math/big"
3031 "net"
3132 "net/http"
@@ -544,6 +545,76 @@ func TestE2E_API_ToolServerCompanionSecrets(t *testing.T) {
544545 assert .Equal (t , v1alpha2 .GroupVersion .Identifier (), or .APIVersion )
545546}
546547
548+ // TestE2E_RemoteMCPServer_STREAMABLE_HTTP_WITH_STANDALONE_SSE reproduces
549+ // github.com/kagent-dev/kagent/issues/1955: a Streamable HTTP server that
550+ // holds the GET (standalone SSE) channel open without responding causes the
551+ // reconciler's tool discovery to fail when http.Client.Timeout fires and
552+ // marks the connection failed before tools/list can complete.
553+ func TestE2E_RemoteMCPServer_STREAMABLE_HTTP_WITH_STANDALONE_SSE (t * testing.T ) {
554+ cli := setupK8sClient (t , false )
555+ mcp := setupMockMCP (t , false , mockmcp.Options {})
556+
557+ // mockmcp.Start() returns the bind address (e.g. http://[::]:PORT) which
558+ // is not dialable from the proxy process itself. Extract the port and
559+ // forward to 127.0.0.1 so the proxy can reach mockmcp locally.
560+ _ , mcpPort , err := net .SplitHostPort (mcp .server .Addr ().String ())
561+ require .NoError (t , err )
562+ localMCPURL := "http://127.0.0.1:" + mcpPort + mockmcp .MCPPath
563+
564+ // Start a server that accepts GET but never responds — simulating a
565+ // spec-compliant MCP server that holds the standalone SSE channel open
566+ // without sending events. POST requests are forwarded to mockmcp so
567+ // initialize/tools-list work normally.
568+ ln , err := net .Listen ("tcp" , ":0" )
569+ require .NoError (t , err )
570+ hangingServer := & http.Server {
571+ Handler : http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
572+ if r .Method == http .MethodGet {
573+ // Block without writing anything back. client.Do in connectSSE
574+ // blocks here until http.Client.Timeout fires, marking the
575+ // connection failed and causing tools/list to fail.
576+ <- r .Context ().Done ()
577+ return
578+ }
579+ proxyReq , err := http .NewRequestWithContext (r .Context (), r .Method , localMCPURL , r .Body )
580+ if err != nil {
581+ http .Error (w , err .Error (), http .StatusBadGateway )
582+ return
583+ }
584+ proxyReq .Header = r .Header .Clone ()
585+ resp , err := http .DefaultTransport .RoundTrip (proxyReq )
586+ if err != nil {
587+ http .Error (w , err .Error (), http .StatusBadGateway )
588+ return
589+ }
590+ defer resp .Body .Close ()
591+ for k , vs := range resp .Header {
592+ for _ , v := range vs {
593+ w .Header ().Add (k , v )
594+ }
595+ }
596+ w .WriteHeader (resp .StatusCode )
597+ io .Copy (w , resp .Body )
598+ }),
599+ }
600+ go hangingServer .Serve (ln )
601+ t .Cleanup (func () {
602+ ctx , cancel := context .WithTimeout (context .Background (), 5 * time .Second )
603+ defer cancel ()
604+ _ = hangingServer .Shutdown (ctx )
605+ })
606+
607+ timeout := metav1.Duration {Duration : 5 * time .Second }
608+ rms := createRMS (t , cli , v1alpha2.RemoteMCPServerSpec {
609+ URL : buildK8sURL ("http://" + ln .Addr ().String ()),
610+ Protocol : v1alpha2 .RemoteMCPServerProtocolStreamableHttp ,
611+ Timeout : & timeout ,
612+ })
613+
614+ assert .NotEmpty (t , rms .Status .DiscoveredTools ,
615+ "tool discovery must succeed even when the server holds the GET SSE channel open" )
616+ }
617+
547618// recordedPaths is a small helper for assertion failure messages — turns
548619// the request recorder's snapshot into a human-readable list of paths
549620// when an expected request doesn't show up.
0 commit comments