@@ -505,4 +505,155 @@ await Assert.ThrowsAnyAsync<Exception>(() =>
505505 MockLoggerProvider . LogMessages ,
506506 m => m . LogLevel == LogLevel . Warning && m . Message . Contains ( "SSE fallback failed" ) ) ;
507507 }
508+
509+ // A 400/404 on the SEP-2575 server/discover probe is protocol negotiation, not transport detection:
510+ // a pre-SEP-2575 server rejects the session-less POST whether it speaks Streamable HTTP or SSE, and
511+ // McpClientImpl.ConnectAsync reads exactly those two statuses as "initialize-handshake server" and
512+ // retries with initialize. The SSE GET can therefore only waste a round trip here.
513+ [ Theory ]
514+ [ InlineData ( HttpStatusCode . NotFound ) ]
515+ [ InlineData ( HttpStatusCode . BadRequest ) ]
516+ public async Task AutoDetectMode_SkipsSseFallback_WhenDiscoverProbeIsRejected ( HttpStatusCode statusCode )
517+ {
518+ var options = new HttpClientTransportOptions
519+ {
520+ Endpoint = new Uri ( "http://localhost" ) ,
521+ TransportMode = HttpTransportMode . AutoDetect ,
522+ Name = "AutoDetect discover probe test client"
523+ } ;
524+
525+ using var mockHttpHandler = new MockHttpHandler ( ) ;
526+ using var httpClient = new HttpClient ( mockHttpHandler ) ;
527+ await using var transport = new HttpClientTransport ( options , httpClient , LoggerFactory ) ;
528+ var getCount = 0 ;
529+
530+ mockHttpHandler . RequestHandler = request =>
531+ {
532+ if ( request . Method == HttpMethod . Get )
533+ {
534+ getCount ++ ;
535+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . MethodNotAllowed ) ) ;
536+ }
537+
538+ return Task . FromResult ( new HttpResponseMessage ( statusCode )
539+ {
540+ Content = new StringContent ( "Not Found" ) ,
541+ } ) ;
542+ } ;
543+
544+ await using var session = await transport . ConnectAsync ( TestContext . Current . CancellationToken ) ;
545+
546+ var ex = await Assert . ThrowsAsync < HttpRequestException > ( ( ) =>
547+ session . SendMessageAsync (
548+ new JsonRpcRequest { Method = RequestMethods . ServerDiscover , Id = new RequestId ( 1 ) } ,
549+ TestContext . Current . CancellationToken ) ) ;
550+
551+ Assert . Equal ( 0 , getCount ) ;
552+ Assert . Equal ( statusCode , ex . Data [ "ModelContextProtocol.HttpStatusCode" ] ) ;
553+ Assert . DoesNotContain (
554+ MockLoggerProvider . LogMessages ,
555+ m => m . Message . Contains ( "falling back to SSE transport" ) ) ;
556+ }
557+
558+ // Skipping the SSE attempt on the discover probe must not strand an SSE-only server: the initialize
559+ // retry that McpClientImpl.ConnectAsync issues next goes through this same transport and still falls
560+ // back to SSE, so such a server is reached one POST later rather than not at all.
561+ [ Fact ]
562+ public async Task AutoDetectMode_StillFallsBackToSse_WhenInitializeFollowsRejectedDiscoverProbe ( )
563+ {
564+ var options = new HttpClientTransportOptions
565+ {
566+ Endpoint = new Uri ( "http://localhost" ) ,
567+ TransportMode = HttpTransportMode . AutoDetect ,
568+ Name = "AutoDetect discover probe then initialize test client"
569+ } ;
570+
571+ using var mockHttpHandler = new MockHttpHandler ( ) ;
572+ using var httpClient = new HttpClient ( mockHttpHandler ) ;
573+ await using var transport = new HttpClientTransport ( options , httpClient , LoggerFactory ) ;
574+ var ssePipe = new Pipe ( ) ;
575+ var sseEndpointPostCount = 0 ;
576+
577+ await ssePipe . Writer . WriteAsync (
578+ System . Text . Encoding . UTF8 . GetBytes ( "event: endpoint\r \n data: /sse-endpoint\r \n \r \n " ) ,
579+ TestContext . Current . CancellationToken ) ;
580+ await ssePipe . Writer . FlushAsync ( TestContext . Current . CancellationToken ) ;
581+
582+ mockHttpHandler . RequestHandler = request =>
583+ {
584+ if ( request . Method == HttpMethod . Get )
585+ {
586+ var content = new StreamContent ( ssePipe . Reader . AsStream ( ) ) ;
587+ content . Headers . ContentType = new ( "text/event-stream" ) ;
588+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . OK ) { Content = content } ) ;
589+ }
590+
591+ if ( request . RequestUri ? . AbsolutePath == "/sse-endpoint" )
592+ {
593+ sseEndpointPostCount ++ ;
594+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . Accepted ) ) ;
595+ }
596+
597+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . NotFound )
598+ {
599+ Content = new StringContent ( "Mcp-Session-Id required" ) ,
600+ } ) ;
601+ } ;
602+
603+ await using var session = await transport . ConnectAsync ( TestContext . Current . CancellationToken ) ;
604+
605+ await Assert . ThrowsAsync < HttpRequestException > ( ( ) =>
606+ session . SendMessageAsync (
607+ new JsonRpcRequest { Method = RequestMethods . ServerDiscover , Id = new RequestId ( 1 ) } ,
608+ TestContext . Current . CancellationToken ) ) ;
609+
610+ await session . SendMessageAsync (
611+ new JsonRpcRequest { Method = RequestMethods . Initialize , Id = new RequestId ( 2 ) } ,
612+ TestContext . Current . CancellationToken ) ;
613+
614+ Assert . Equal ( 1 , sseEndpointPostCount ) ;
615+
616+ await ssePipe . Writer . CompleteAsync ( ) ;
617+ }
618+
619+ // The skip is scoped to the two statuses ConnectAsync acts on. Any other failure on the discover probe
620+ // keeps the original fallback, because it is not evidence that an initialize retry is coming.
621+ [ Fact ]
622+ public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrelatedStatus ( )
623+ {
624+ var options = new HttpClientTransportOptions
625+ {
626+ Endpoint = new Uri ( "http://localhost" ) ,
627+ TransportMode = HttpTransportMode . AutoDetect ,
628+ Name = "AutoDetect discover probe 415 test client"
629+ } ;
630+
631+ using var mockHttpHandler = new MockHttpHandler ( ) ;
632+ using var httpClient = new HttpClient ( mockHttpHandler ) ;
633+ await using var transport = new HttpClientTransport ( options , httpClient , LoggerFactory ) ;
634+ var getCount = 0 ;
635+
636+ mockHttpHandler . RequestHandler = request =>
637+ {
638+ if ( request . Method == HttpMethod . Get )
639+ {
640+ getCount ++ ;
641+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . MethodNotAllowed ) ) ;
642+ }
643+
644+ return Task . FromResult ( new HttpResponseMessage ( HttpStatusCode . UnsupportedMediaType )
645+ {
646+ Content = new StringContent ( "Content-Type must be 'application/json'" ) ,
647+ } ) ;
648+ } ;
649+
650+ await using var session = await transport . ConnectAsync ( TestContext . Current . CancellationToken ) ;
651+
652+ await Assert . ThrowsAsync < HttpRequestException > ( ( ) =>
653+ session . SendMessageAsync (
654+ new JsonRpcRequest { Method = RequestMethods . ServerDiscover , Id = new RequestId ( 1 ) } ,
655+ TestContext . Current . CancellationToken ) ) ;
656+
657+ Assert . Equal ( 1 , getCount ) ;
658+ }
508659}
0 commit comments