Support progress notifications with McpClientTool and McpClientExtensions.CallToolAsync (#312)

This commit is contained in:
Stephen Toub 2025-04-16 09:03:07 -04:00 • committed by GitHub
commit 227f440ab9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 174 additions and 47 deletions

View file

@ -29,7 +29,6 @@ public class McpClientExtensionsTests : ClientServerTestBase
}
services.AddSingleton(McpServerTool.Create([McpServerTool(Destructive = false, OpenWorld = true)] (string i) => $"{i} Result", new() { Name = "ValuesSetViaAttr" }));
services.AddSingleton(McpServerTool.Create([McpServerTool(Destructive = false, OpenWorld = true)] (string i) => $"{i} Result", new() { Name = "ValuesSetViaOptions", Destructive = true, OpenWorld = false, ReadOnly = true }));
}
[Theory]
@ -209,7 +208,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
[Fact]
public async Task ListToolsAsync_AllToolsReturned()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(12, tools.Count);
@ -235,7 +234,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
[Fact]
public async Task EnumerateToolsAsync_AllToolsReturned()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
await foreach (var tool in client.EnumerateToolsAsync(cancellationToken: TestContext.Current.CancellationToken))
{
@ -254,7 +253,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task EnumerateToolsAsync_FlowsJsonSerializerOptions()
{
JsonSerializerOptions options = new(JsonSerializerOptions.Default);
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
bool hasTools = false;
await foreach (var tool in client.EnumerateToolsAsync(options, TestContext.Current.CancellationToken))
@ -275,7 +274,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task EnumerateToolsAsync_HonorsJsonSerializerOptions()
{
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tool = (await client.ListToolsAsync(emptyOptions, TestContext.Current.CancellationToken)).First();
await Assert.ThrowsAsync<NotSupportedException>(async () => await tool.InvokeAsync(new() { ["i"] = 42 }, TestContext.Current.CancellationToken));
@ -285,7 +284,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task SendRequestAsync_HonorsJsonSerializerOptions()
{
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<NotSupportedException>(() => client.SendRequestAsync<CallToolRequestParams, CallToolResponse>("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
}
@ -294,7 +293,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task SendNotificationAsync_HonorsJsonSerializerOptions()
{
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<NotSupportedException>(() => client.SendNotificationAsync("Method4", new { Value = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
}
@ -303,7 +302,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task GetPromptsAsync_HonorsJsonSerializerOptions()
{
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<NotSupportedException>(() => client.GetPromptAsync("Prompt", new Dictionary<string, object?> { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
}
@ -312,7 +311,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task WithName_ChangesToolName()
{
JsonSerializerOptions options = new(JsonSerializerOptions.Default);
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tool = (await client.ListToolsAsync(options, TestContext.Current.CancellationToken)).First();
var originalName = tool.Name;
@ -327,7 +326,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
public async Task WithDescription_ChangesToolDescription()
{
JsonSerializerOptions options = new(JsonSerializerOptions.Default);
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tool = (await client.ListToolsAsync(options, TestContext.Current.CancellationToken)).FirstOrDefault();
var originalDescription = tool?.Description;
var redescribedTool = tool?.WithDescription("ToolWithNewDescription");
@ -336,10 +335,55 @@ public class McpClientExtensionsTests : ClientServerTestBase
Assert.Equal(originalDescription, tool?.Description);
}
[Fact]
public async Task WithProgress_ProgressReported()
{
const int TotalNotifications = 3;
int remainingProgress = TotalNotifications;
TaskCompletionSource<bool> allProgressReceived = new(TaskCreationOptions.RunContinuationsAsynchronously);
Server.ServerOptions.Capabilities?.Tools?.ToolCollection?.Add(McpServerTool.Create(async (IProgress<ProgressNotificationValue> progress) =>
{
for (int i = 0; i < TotalNotifications; i++)
{
progress.Report(new ProgressNotificationValue { Progress = i * 10, Message = "making progress" });
await Task.Delay(1);
}
await allProgressReceived.Task;
return 42;
}, new() { Name = "ProgressReporter" }));
await using IMcpClient client = await CreateMcpClientForServer();
var tool = (await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)).First(t => t.Name == "ProgressReporter");
IProgress<ProgressNotificationValue> progress = new SynchronousProgress(value =>
{
Assert.True(value.Progress >= 0 && value.Progress <= 100);
Assert.Equal("making progress", value.Message);
if (Interlocked.Decrement(ref remainingProgress) == 0)
{
allProgressReceived.SetResult(true);
}
});
Assert.Throws<ArgumentNullException>("progress", () => tool.WithProgress(null!));
var result = await tool.WithProgress(progress).InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains("42", result?.ToString());
}
private sealed class SynchronousProgress(Action<ProgressNotificationValue> callback) : IProgress<ProgressNotificationValue>
{
public void Report(ProgressNotificationValue value) => callback(value);
}
[Fact]
public async Task AsClientLoggerProvider_MessagesSentToClient()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
ILoggerProvider loggerProvider = Server.AsClientLoggerProvider();
Assert.Throws<ArgumentNullException>("categoryName", () => loggerProvider.CreateLogger(null!));

View file

@ -96,7 +96,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
[Fact]
public async Task Can_List_And_Call_Registered_Prompts()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
Assert.Equal(6, prompts.Count);
@ -125,7 +125,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
[Fact]
public async Task Can_Be_Notified_Of_Prompt_Changes()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
Assert.Equal(6, prompts.Count);
@ -166,7 +166,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
[Fact]
public async Task Throws_When_Prompt_Fails()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
nameof(SimplePrompts.ThrowsException),
@ -176,7 +176,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
[Fact]
public async Task Throws_Exception_On_Unknown_Prompt()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
"NotRegisteredPrompt",
@ -188,7 +188,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
[Fact]
public async Task Throws_Exception_Missing_Parameter()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
nameof(SimplePrompts.ReturnsChatMessages),

View file

@ -124,7 +124,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_List_Registered_Tools()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, tools.Count);
@ -190,7 +190,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Be_Notified_Of_Tool_Changes()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, tools.Count);
@ -231,7 +231,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"Echo",
@ -249,7 +249,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_With_Array_Result()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"EchoArray",
@ -273,7 +273,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_With_Null_Result()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnNull",
@ -287,7 +287,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_With_Json_Result()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnJson",
@ -304,7 +304,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_With_Int_Result()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnInteger",
@ -320,7 +320,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_And_Pass_ComplexType()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"EchoComplex",
@ -338,7 +338,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Can_Call_Registered_Tool_With_Instance_Method()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
string[][] parts = new string[2][];
for (int i = 0; i < 2; i++)
@ -367,7 +367,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Returns_IsError_Content_When_Tool_Fails()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ThrowException",
@ -382,7 +382,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Throws_Exception_On_Unknown_Tool()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync(
"NotRegisteredTool",
@ -394,7 +394,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Returns_IsError_Missing_Parameter()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"Echo",
@ -511,7 +511,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task Recognizes_Parameter_Types()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
@ -584,7 +584,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task HandlesIProgressParameter()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tools);
@ -627,7 +627,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public async Task CancellationNotificationsPropagateToToolTokens()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tools);

View file

@ -23,7 +23,7 @@ public partial class McpServerScopedTests : ClientServerTestBase
[Fact]
public async Task InjectScopedServiceAsArgument()
{
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(McpServerScopedTestsJsonContext.Default.Options, TestContext.Current.CancellationToken);
var tool = tools.First(t => t.Name == nameof(EchoTool.EchoComplex));

View file

@ -33,7 +33,7 @@ public class CancellationTests : ClientServerTestBase
[Fact]
public async Task PrecancelRequest_CancelsBeforeSending()
{
var client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
bool gotCancellation = false;
await using (Server.RegisterNotificationHandler(NotificationMethods.CancelledNotification, (notification, cancellationToken) =>
@ -51,7 +51,7 @@ public class CancellationTests : ClientServerTestBase
[Fact]
public async Task CancellationPropagation_RequestingCancellationCancelsPendingRequest()
{
var client = await CreateMcpClientForServer();
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var waitTool = tools.First(t => t.Name == nameof(WaitForCancellation));

View file

@ -13,7 +13,7 @@ public class NotificationHandlerTests : ClientServerTestBase
public async Task RegistrationsAreRemovedWhenDisposed()
{
const string NotificationName = "somethingsomething";
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
const int Iterations = 10;
@ -40,7 +40,7 @@ public class NotificationHandlerTests : ClientServerTestBase
public async Task MultipleRegistrationsResultInMultipleCallbacks()
{
const string NotificationName = "somethingsomething";
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
const int RegistrationCount = 10;
@ -80,7 +80,7 @@ public class NotificationHandlerTests : ClientServerTestBase
public async Task MultipleHandlersRunEvenIfOneThrows()
{
const string NotificationName = "somethingsomething";
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
const int RegistrationCount = 10;
@ -122,7 +122,7 @@ public class NotificationHandlerTests : ClientServerTestBase
public async Task DisposeAsyncDoesNotCompleteWhileNotificationHandlerRuns(int numberOfDisposals)
{
const string NotificationName = "somethingsomething";
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var handlerRunning = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseHandler = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@ -163,7 +163,7 @@ public class NotificationHandlerTests : ClientServerTestBase
public async Task DisposeAsyncCompletesImmediatelyWhenInvokedFromHandler(int numberOfDisposals)
{
const string NotificationName = "somethingsomething";
IMcpClient client = await CreateMcpClientForServer();
await using IMcpClient client = await CreateMcpClientForServer();
var handlerRunning = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseHandler = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);