Add tests to verify McpClient.DisposeAsync doesn't hang (#1252)

This commit is contained in:
Stephen Halter 2026-02-12 19:26:30 -08:00 • committed by GitHub
commit b4c7ded398
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -281,6 +281,95 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
Assert.Contains(nameof(McpClient.ResumeSessionAsync), exception.Message);
}
[Fact]
public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetStream()
{
var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
var echoTool = McpServerTool.Create(Echo, new() { Services = _app.Services });
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
{
if (message is not JsonRpcRequest request)
{
return Results.Accepted();
}
context.Response.Headers.Append("mcp-session-id", "hang-test-session");
if (request.Method == "initialize")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "2024-11-05",
Capabilities = new() { Tools = new() },
ServerInfo = new Implementation { Name = "hang-test", Version = "0.0.1" },
}, McpJsonUtilities.DefaultOptions)
});
}
if (request.Method == "tools/list")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new ListToolsResult
{
Tools = [echoTool.ProtocolTool]
}, McpJsonUtilities.DefaultOptions),
});
}
return Results.Accepted();
});
// GET handler that keeps the SSE stream open indefinitely (like a real MCP server)
_app.MapGet("/mcp", async context =>
{
context.Response.Headers.ContentType = "text/event-stream";
getRequestReceived.TrySetResult();
await context.Response.Body.FlushAsync(TestContext.Current.CancellationToken);
try
{
await Task.Delay(Timeout.Infinite, context.RequestAborted);
}
catch (OperationCanceledException)
{
}
});
await _app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
OwnsSession = false,
}, HttpClient, LoggerFactory);
await using (var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(tools);
// Wait for the GET SSE stream to be established on the server
await getRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// Dispose should not hang even though the GET stream is actively open
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
}
}
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
{
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);