@@ -412,4 +412,139 @@ public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests()
412412 Assert . True ( wasPostRequest , "POST request was not made" ) ;
413413 Assert . True ( wasDeleteRequest , "DELETE request was not made" ) ;
414414 }
415+
416+ [ Fact ]
417+ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse ( )
418+ {
419+ Assert . SkipWhen ( Stateless , "Stateless mode doesn't support session management." ) ;
420+
421+ var getResponseStarted = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
422+
423+ Builder . Services . AddMcpServer ( ) . WithHttpTransport ( ConfigureStateless ) . WithTools < ClaimsPrincipalTools > ( ) ;
424+
425+ await using var app = Builder . Build ( ) ;
426+
427+ // Track when the GET SSE response starts being written, which indicates
428+ // the server's HandleGetRequestAsync has fully initialized the SSE writer.
429+ app . Use ( next =>
430+ {
431+ return async context =>
432+ {
433+ if ( context . Request . Method == HttpMethods . Get )
434+ {
435+ context . Response . OnStarting ( ( ) =>
436+ {
437+ getResponseStarted . TrySetResult ( ) ;
438+ return Task . CompletedTask ;
439+ } ) ;
440+ }
441+ await next ( context ) ;
442+ } ;
443+ } ) ;
444+
445+ app . MapMcp ( ) ;
446+ await app . StartAsync ( TestContext . Current . CancellationToken ) ;
447+
448+ await using var transport = new HttpClientTransport ( new ( )
449+ {
450+ Endpoint = new ( "http://localhost:5000/" ) ,
451+ TransportMode = HttpTransportMode . StreamableHttp ,
452+ OwnsSession = false ,
453+ } , HttpClient , LoggerFactory ) ;
454+
455+ var client = await McpClient . CreateAsync ( transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
456+
457+ // Call a tool to ensure the session is fully established
458+ var result = await client . CallToolAsync (
459+ "echo_claims_principal" ,
460+ new Dictionary < string , object ? > ( ) { [ "message" ] = "Hello!" } ,
461+ cancellationToken : TestContext . Current . CancellationToken ) ;
462+
463+ Assert . NotNull ( result ) ;
464+
465+ // Wait for the GET SSE stream to be fully established on the server
466+ await getResponseStarted . Task . WaitAsync ( TestConstants . DefaultTimeout , TestContext . Current . CancellationToken ) ;
467+
468+ // This should not hang. The issue reports that DisposeAsync hangs indefinitely
469+ // when OwnsSession is false. Use a timeout to detect the hang.
470+ await client . DisposeAsync ( ) . AsTask ( ) . WaitAsync ( TimeSpan . FromSeconds ( 10 ) , TestContext . Current . CancellationToken ) ;
471+ }
472+
473+ [ Fact ]
474+ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithUnsolicitedMessages ( )
475+ {
476+ Assert . SkipWhen ( Stateless , "Stateless mode doesn't support session management." ) ;
477+
478+ var getResponseStarted = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
479+ var serverTcs = new TaskCompletionSource < McpServer > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
480+
481+ Builder . Services . AddMcpServer ( ) . WithHttpTransport ( opts =>
482+ {
483+ ConfigureStateless ( opts ) ;
484+ opts . RunSessionHandler = async ( context , server , cancellationToken ) =>
485+ {
486+ serverTcs . TrySetResult ( server ) ;
487+ await server . RunAsync ( cancellationToken ) ;
488+ } ;
489+ } ) . WithTools < ClaimsPrincipalTools > ( ) ;
490+
491+ await using var app = Builder . Build ( ) ;
492+
493+ // Track when the GET SSE response starts being written, which indicates
494+ // the server's HandleGetRequestAsync has fully initialized the SSE writer.
495+ app . Use ( next =>
496+ {
497+ return async context =>
498+ {
499+ if ( context . Request . Method == HttpMethods . Get )
500+ {
501+ context . Response . OnStarting ( ( ) =>
502+ {
503+ getResponseStarted . TrySetResult ( ) ;
504+ return Task . CompletedTask ;
505+ } ) ;
506+ }
507+ await next ( context ) ;
508+ } ;
509+ } ) ;
510+
511+ app . MapMcp ( ) ;
512+ await app . StartAsync ( TestContext . Current . CancellationToken ) ;
513+
514+ await using var transport = new HttpClientTransport ( new ( )
515+ {
516+ Endpoint = new ( "http://localhost:5000/" ) ,
517+ TransportMode = HttpTransportMode . StreamableHttp ,
518+ OwnsSession = false ,
519+ } , HttpClient , LoggerFactory ) ;
520+
521+ var client = await McpClient . CreateAsync ( transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ;
522+
523+ var result = await client . CallToolAsync (
524+ "echo_claims_principal" ,
525+ new Dictionary < string , object ? > ( ) { [ "message" ] = "Hello!" } ,
526+ cancellationToken : TestContext . Current . CancellationToken ) ;
527+ Assert . NotNull ( result ) ;
528+
529+ // Wait for the GET SSE stream to be fully established on the server
530+ await getResponseStarted . Task . WaitAsync ( TestConstants . DefaultTimeout , TestContext . Current . CancellationToken ) ;
531+
532+ // Register a handler on the client to detect when the notification is received
533+ var notificationReceived = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
534+ await using var handlerRegistration = client . RegisterNotificationHandler ( "notifications/tools/list_changed" , ( notification , ct ) =>
535+ {
536+ notificationReceived . TrySetResult ( ) ;
537+ return default ;
538+ } ) ;
539+
540+ // Get the server instance and send an unsolicited notification by modifying tools
541+ var server = await serverTcs . Task . WaitAsync ( TestConstants . DefaultTimeout , TestContext . Current . CancellationToken ) ;
542+ await server . SendNotificationAsync ( "notifications/tools/list_changed" , TestContext . Current . CancellationToken ) ;
543+
544+ // Wait for the client to actually receive the notification
545+ await notificationReceived . Task . WaitAsync ( TestConstants . DefaultTimeout , TestContext . Current . CancellationToken ) ;
546+
547+ // Dispose should still not hang
548+ await client . DisposeAsync ( ) . AsTask ( ) . WaitAsync ( TimeSpan . FromSeconds ( 10 ) , TestContext . Current . CancellationToken ) ;
549+ }
415550}
0 commit comments