Add DeferChangedEvents() to McpServerPrimitiveCollection for batched change notifications (#1689)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
1214556707
commit
84eb6942eb
3 changed files with 814 additions and 1 deletions
|
|
@ -173,6 +173,50 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification()
|
||||
{
|
||||
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
|
||||
});
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
var serverPrompts = serverOptions.PromptCollection;
|
||||
Assert.NotNull(serverPrompts);
|
||||
|
||||
int notificationCount = 0;
|
||||
var firstNotification = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref notificationCount) == 1)
|
||||
{
|
||||
firstNotification.TrySetResult(true);
|
||||
}
|
||||
return default;
|
||||
}))
|
||||
{
|
||||
using (serverPrompts.DeferChangedEvents())
|
||||
{
|
||||
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1"));
|
||||
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2"));
|
||||
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3"));
|
||||
}
|
||||
|
||||
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Do a round-trip so that any second (erroneous) notification has time to arrive.
|
||||
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Contains(prompts, t => t.Name == "BatchPrompt1");
|
||||
Assert.Contains(prompts, t => t.Name == "BatchPrompt2");
|
||||
Assert.Contains(prompts, t => t.Name == "BatchPrompt3");
|
||||
|
||||
Assert.Equal(1, notificationCount);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributeProperties_Propagated()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -207,6 +207,50 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
Assert.DoesNotContain(resources, t => t.Name == "NewResource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotification()
|
||||
{
|
||||
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
|
||||
});
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
var serverResources = serverOptions.ResourceCollection;
|
||||
Assert.NotNull(serverResources);
|
||||
|
||||
int notificationCount = 0;
|
||||
var firstNotification = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref notificationCount) == 1)
|
||||
{
|
||||
firstNotification.TrySetResult(true);
|
||||
}
|
||||
return default;
|
||||
}))
|
||||
{
|
||||
using (serverResources.DeferChangedEvents())
|
||||
{
|
||||
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1"));
|
||||
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2"));
|
||||
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3"));
|
||||
}
|
||||
|
||||
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Do a round-trip so that any second (erroneous) notification has time to arrive.
|
||||
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Contains(resources, t => t.Name == "BatchResource1");
|
||||
Assert.Contains(resources, t => t.Name == "BatchResource2");
|
||||
Assert.Contains(resources, t => t.Name == "BatchResource3");
|
||||
|
||||
Assert.Equal(1, notificationCount);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributeProperties_Propagated()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -232,6 +232,50 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification()
|
||||
{
|
||||
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
|
||||
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
|
||||
});
|
||||
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
var serverTools = serverOptions.ToolCollection;
|
||||
Assert.NotNull(serverTools);
|
||||
|
||||
int notificationCount = 0;
|
||||
var firstNotification = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref notificationCount) == 1)
|
||||
{
|
||||
firstNotification.TrySetResult(true);
|
||||
}
|
||||
return default;
|
||||
}))
|
||||
{
|
||||
using (serverTools.DeferChangedEvents())
|
||||
{
|
||||
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1"));
|
||||
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2"));
|
||||
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3"));
|
||||
}
|
||||
|
||||
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Do a round-trip so that any second (erroneous) notification has time to arrive.
|
||||
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Contains(tools, t => t.Name == "BatchTool1");
|
||||
Assert.Contains(tools, t => t.Name == "BatchTool2");
|
||||
Assert.Contains(tools, t => t.Name == "BatchTool3");
|
||||
|
||||
Assert.Equal(1, notificationCount);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Call_Registered_Tool()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue