Add client completion notification and details (#1368)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Toub 2026-03-02 09:17:22 -05:00 • committed by GitHub
commit b965a7eca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;
@ -7,6 +7,7 @@ using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
@ -370,6 +371,159 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
}
}
[Fact]
public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails()
{
bool expireSession = false;
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
{
if (message is not JsonRpcRequest request)
{
return Results.Accepted();
}
context.Response.Headers.Append("mcp-session-id", "expiry-test-session");
if (expireSession)
{
return Results.NotFound();
}
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 = "expiry-test", Version = "0.0.1" },
}, McpJsonUtilities.DefaultOptions)
});
}
return Results.Accepted();
});
await _app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("expiry-test-session", client.SessionId);
Assert.False(client.Completion.IsCompleted);
// Simulate session expiry by having the server return 404
expireSession = true;
await Assert.ThrowsAnyAsync<Exception>(async () =>
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
var details = await client.Completion.WaitAsync(TestContext.Current.CancellationToken);
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
Assert.NotNull(httpDetails.Exception);
}
[Fact]
public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails()
{
var expireSession = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
{
if (message is not JsonRpcRequest request)
{
return Results.Accepted();
}
context.Response.Headers.Append("mcp-session-id", "get-expiry-test");
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 = "get-expiry-test", Version = "0.0.1" },
}, McpJsonUtilities.DefaultOptions)
});
}
return Results.Accepted();
});
// GET handler waits for the signal, then returns 404 to simulate session expiry
_app.MapGet("/mcp", async (HttpContext context) =>
{
await expireSession.Task;
context.Response.StatusCode = StatusCodes.Status404NotFound;
});
await _app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("get-expiry-test", client.SessionId);
// Trigger session expiry on the GET SSE stream
expireSession.SetResult();
var details = await client.Completion.WaitAsync(TestContext.Current.CancellationToken);
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
Assert.NotNull(httpDetails.Exception);
}
[Fact]
public async Task Completion_GracefulDisposal_ReturnsCompletionDetails()
{
await StartAsync(enableDelete: true);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(client.Completion.IsCompleted);
await client.DisposeAsync();
Assert.True(client.Completion.IsCompleted);
var details = await client.Completion;
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
Assert.Null(httpDetails.Exception);
Assert.Null(httpDetails.HttpStatusCode);
}
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
{
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);