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:
Copilot 2026-07-07 15:15:45 -04:00 • committed by GitHub
commit 84eb6942eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 814 additions and 1 deletions

View file

@ -12,6 +12,15 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
/// <summary>Concurrent dictionary of primitives, indexed by their names.</summary>
private readonly ConcurrentDictionary<string, T> _primitives;
/// <summary>Lock protecting <see cref="_activeDeferralScopes"/> and <see cref="_hasDeferredChangeEvents"/>.</summary>
private readonly object _deferralLock = new();
/// <summary>Depth counter for active <see cref="DeferChangedEvents"/> scopes. Positive means notifications are deferred.</summary>
private int _activeDeferralScopes;
/// <summary>Whether a change occurred while notifications were deferred.</summary>
private bool _hasDeferredChangeEvents;
/// <summary>
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
/// </summary>
@ -33,8 +42,85 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
/// <summary>Gets a value that indicates whether there are any primitives in the collection.</summary>
public bool IsEmpty => _primitives.IsEmpty;
/// <summary>
/// Begins a deferred-change scope. <see cref="Changed"/> notifications are suppressed
/// until the returned scope is disposed, at which point a single notification is raised
/// if any mutation occurred during the scope. Multiple scopes may be active simultaneously;
/// the notification fires once all active scopes have been disposed.
/// </summary>
/// <returns>An <see cref="IDisposable"/> that ends the deferral scope when disposed.</returns>
/// <remarks>
/// The scope is exception-safe: even if an exception is thrown inside a <c>using</c> block,
/// the deferral is ended on dispose. If any mutation occurred before the exception, a single
/// <see cref="Changed"/> notification is raised.
/// <para>
/// Mutations from any thread during an open scope are coalesced. A single <see cref="Changed"/>
/// notification fires on the thread that disposes the last active scope, only if at least one
/// mutation occurred. All deferral state transitions are guarded by an internal lock, so
/// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope
/// instance more than once is safe and has no additional effect.
/// </para>
/// </remarks>
public IDisposable DeferChangedEvents()
{
lock (_deferralLock)
{
_activeDeferralScopes++;
}
return new ChangeDeferralScope(this);
}
/// <summary>Raises <see cref="Changed"/> if there are registered handlers.</summary>
protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
/// <remarks>
/// If a <see cref="DeferChangedEvents"/> scope is active, the notification is deferred until all
/// active scopes are disposed. Derived types that override mutation methods and call
/// <see cref="RaiseChanged"/> will automatically participate in deferral.
/// </remarks>
protected void RaiseChanged()
{
lock (_deferralLock)
{
if (_activeDeferralScopes > 0)
{
_hasDeferredChangeEvents = true;
return;
}
}
Changed?.Invoke(this, EventArgs.Empty);
}
private void EndDeferral()
{
bool raise;
lock (_deferralLock)
{
raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents;
if (raise)
{
_hasDeferredChangeEvents = false;
}
}
if (raise)
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
private sealed class ChangeDeferralScope : IDisposable
{
private McpServerPrimitiveCollection<T>? _collection;
public ChangeDeferralScope(McpServerPrimitiveCollection<T> collection) =>
_collection = collection;
public void Dispose()
{
McpServerPrimitiveCollection<T>? collection = Interlocked.Exchange(ref _collection, null);
collection?.EndDeferral();
}
}
/// <summary>Gets the <typeparamref name="T"/> with the specified <paramref name="name"/> from the collection.</summary>
/// <param name="name">The name of the primitive to retrieve.</param>

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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);
}
}