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
4 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,595 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
public class McpServerPrimitiveCollectionTests
|
||||
{
|
||||
private static McpServerTool CreateTool(string name) =>
|
||||
McpServerTool.Create(() => name, new() { Name = name });
|
||||
|
||||
private static McpServerPrompt CreatePrompt(string name) =>
|
||||
McpServerPrompt.Create(() => new ChatMessage(ChatRole.User, name), new() { Name = name });
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Changed event without DeferChangedEvents
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_NewTool_ReturnsTrue_FiresChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
bool added = collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
Assert.True(added);
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_DuplicateName_ReturnsFalse_DoesNotFireChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
bool added = collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
Assert.False(added);
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_SameTool_TwiceInSequence_FiresOnlyOnFirst()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
bool first = collection.TryAdd(CreateTool("tool1"));
|
||||
bool second = collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_ExistingTool_ReturnsTrue_FiresChanged()
|
||||
{
|
||||
var tool = CreateTool("tool1");
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(tool);
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
bool removed = collection.Remove(tool);
|
||||
|
||||
Assert.True(removed);
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_NonExistentTool_ReturnsFalse_DoesNotFireChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
bool removed = collection.Remove(CreateTool("tool1"));
|
||||
|
||||
Assert.False(removed);
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_NonEmptyCollection_FiresChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
collection.Clear();
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
Assert.Empty(collection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_EmptyCollection_FiresChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
collection.Clear();
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DeferChangedEvents -- basic deferral behavior
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_NoMutation_DoesNotFireChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
// no mutations
|
||||
}
|
||||
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_SingleMutation_FiresOneChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
Assert.Equal(0, changeCount); // not fired yet
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_MultipleMutations_FiresOneChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
collection.TryAdd(CreateTool("tool3"));
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_MixedAddAndRemove_FiresOneChanged()
|
||||
{
|
||||
var tool = CreateTool("tool1");
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(tool);
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
collection.Remove(tool);
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_AddThenRemoveSameTool_FiresOneChanged()
|
||||
{
|
||||
// Net effect is no change in contents, but a Changed notification still fires
|
||||
// because mutations occurred during the scope.
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
var tool = CreateTool("tool1");
|
||||
collection.TryAdd(tool);
|
||||
collection.Remove(tool);
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
Assert.Empty(collection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_DuplicateTryAdd_OnlySuccessfulMutationMarksChange()
|
||||
{
|
||||
// The first TryAdd succeeds (mutation), the second fails (no mutation).
|
||||
// Exactly one Changed fires on dispose.
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1")); // succeeds
|
||||
collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_OnlyFailedTryAdds_DoesNotFireChanged()
|
||||
{
|
||||
var tool = CreateTool("tool1");
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(tool);
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1")); // fails -- already present
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_WithClear_FiresOneChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.Clear();
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_Nested_FiresOnceWhenAllScopesDisposed()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(0, changeCount); // inner scope disposed, but outer still active
|
||||
collection.TryAdd(CreateTool("tool3"));
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_OutOfOrderDisposal_FiresOnceWhenAllScopesDisposed()
|
||||
{
|
||||
// Scopes created in order 1, 2 but disposed in reverse order 2, 1.
|
||||
// Changed should NOT fire when scope 2 is disposed (scope 1 still active).
|
||||
// Changed SHOULD fire when scope 1 is disposed (last active scope gone).
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
var scope1 = collection.DeferChangedEvents();
|
||||
var scope2 = collection.DeferChangedEvents();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
Assert.Equal(0, changeCount);
|
||||
scope2.Dispose(); // out-of-order dispose; scope1 still active
|
||||
Assert.Equal(0, changeCount);
|
||||
|
||||
scope1.Dispose(); // last scope disposed; Changed fires now
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_DoubleDisposeSingleScope_DoesNotDecrementCountTwice()
|
||||
{
|
||||
// Double-disposing scope1 must not decrement _activeDeferralScopes more than once,
|
||||
// which would cause Changed to fire while scope2 is still active.
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
var scope1 = collection.DeferChangedEvents();
|
||||
var scope2 = collection.DeferChangedEvents();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
scope1.Dispose();
|
||||
Assert.Equal(0, changeCount); // scope2 still active
|
||||
|
||||
scope1.Dispose(); // second dispose of scope1 -- must be a no-op
|
||||
Assert.Equal(0, changeCount); // scope2 is still active; Changed must NOT fire yet
|
||||
|
||||
scope2.Dispose(); // now all scopes are disposed; Changed fires
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_OutOfOrderDisposalNoMutation_DoesNotFireChanged()
|
||||
{
|
||||
// Same out-of-order pattern but with no mutations -- verifies no spurious Changed.
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
var scope1 = collection.DeferChangedEvents();
|
||||
var scope2 = collection.DeferChangedEvents();
|
||||
|
||||
scope2.Dispose();
|
||||
scope1.Dispose();
|
||||
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_AfterScope_ResumesImmediateNotifications()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
|
||||
// After the scope, each mutation fires immediately
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
Assert.Equal(2, changeCount);
|
||||
|
||||
collection.TryAdd(CreateTool("tool3"));
|
||||
Assert.Equal(3, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_DisposeIdempotent_DoesNotFireTwice()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
var scope = collection.DeferChangedEvents();
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
|
||||
scope.Dispose();
|
||||
Assert.Equal(1, changeCount);
|
||||
|
||||
scope.Dispose(); // second dispose should be a no-op
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_ScopeWithNoHandlers_DoesNotThrow()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
// no Changed handler registered
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
}
|
||||
|
||||
Assert.Single(collection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutDeferChangedEvents_EachMutationFiresImmediately()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
Assert.Equal(1, changeCount);
|
||||
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
Assert.Equal(2, changeCount);
|
||||
|
||||
collection.TryAdd(CreateTool("tool3"));
|
||||
Assert.Equal(3, changeCount);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DeferChangedEvents -- concurrency
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task DeferChangedEvents_ConcurrentMutations_FiresExactlyOneChanged()
|
||||
{
|
||||
const int threadCount = 10;
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => Interlocked.Increment(ref changeCount);
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i =>
|
||||
Task.Run(() => collection.TryAdd(CreateTool($"tool{i}")), TestContext.Current.CancellationToken)));
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
Assert.Equal(threadCount, collection.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferChangedEvents_MutationRacingWithDispose_NotificationNotLost()
|
||||
{
|
||||
// Run many iterations to reliably exercise the race between a mutation
|
||||
// and disposal of the outermost scope. With the lock-free implementation
|
||||
// the race could cause the notification to be lost; the lock-based
|
||||
// implementation must always fire exactly one notification.
|
||||
for (int iteration = 0; iteration < 200; iteration++)
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => Interlocked.Increment(ref changeCount);
|
||||
|
||||
var scope = collection.DeferChangedEvents();
|
||||
|
||||
// Run the mutation and the dispose concurrently.
|
||||
var addTask = Task.Run(() => collection.TryAdd(CreateTool("tool1")), TestContext.Current.CancellationToken);
|
||||
var disposeTask = Task.Run(() => scope.Dispose(), TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.WhenAll(addTask, disposeTask);
|
||||
|
||||
// Regardless of ordering: exactly one notification must have fired.
|
||||
// - If TryAdd runs before Dispose: the mutation marks _pendingChange;
|
||||
// Dispose sees depth -> 0 with a pending change and fires.
|
||||
// - If Dispose runs before TryAdd: depth is already 0 when TryAdd
|
||||
// calls RaiseChanged, so it fires immediately.
|
||||
// The lock prevents the third (buggy) interleaving where Dispose
|
||||
// sees no pending change and TryAdd sees depth > 0, dropping the event.
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DeferChangedEvents -- derived-type coalescing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private sealed class TrackingCollection : McpServerPrimitiveCollection<McpServerTool>
|
||||
{
|
||||
public void RaiseChangedDirectly() => RaiseChanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_DerivedTypeCallsRaiseChanged_Coalesces()
|
||||
{
|
||||
// Verify that derived types calling RaiseChanged() directly (the path
|
||||
// McpServerResourceCollection and other subclasses rely on) are gated
|
||||
// by the same deferral check.
|
||||
var collection = new TrackingCollection();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.RaiseChangedDirectly();
|
||||
collection.RaiseChangedDirectly();
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately()
|
||||
{
|
||||
var collection = new TrackingCollection();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
collection.RaiseChangedDirectly();
|
||||
Assert.Equal(1, changeCount);
|
||||
|
||||
collection.RaiseChangedDirectly();
|
||||
Assert.Equal(2, changeCount);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DeferChangedEvents -- exception safety
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_ExceptionDuringScope_StillFiresChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
try
|
||||
{
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
throw new InvalidOperationException("test");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_ExceptionDuringScope_ResumesImmediateNotificationsAfterward()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
try
|
||||
{
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreateTool("tool1"));
|
||||
throw new InvalidOperationException("test");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
|
||||
// Deferral must be fully reset: mutations outside the scope fire immediately.
|
||||
collection.TryAdd(CreateTool("tool2"));
|
||||
Assert.Equal(2, changeCount);
|
||||
|
||||
collection.TryAdd(CreateTool("tool3"));
|
||||
Assert.Equal(3, changeCount);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DeferChangedEvents -- prompt collection coverage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeferChangedEvents_PromptCollection_MultipleMutations_FiresOneChanged()
|
||||
{
|
||||
var collection = new McpServerPrimitiveCollection<McpServerPrompt>();
|
||||
int changeCount = 0;
|
||||
collection.Changed += (_, _) => changeCount++;
|
||||
|
||||
using (collection.DeferChangedEvents())
|
||||
{
|
||||
collection.TryAdd(CreatePrompt("prompt1"));
|
||||
collection.TryAdd(CreatePrompt("prompt2"));
|
||||
collection.TryAdd(CreatePrompt("prompt3"));
|
||||
Assert.Equal(0, changeCount);
|
||||
}
|
||||
|
||||
Assert.Equal(1, changeCount);
|
||||
Assert.Equal(3, collection.Count);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue