From 4418d629e5a4d621fef2d5786ada4c82f989b8d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:48:46 +0000 Subject: [PATCH 01/11] Initial plan From f111e42c1253c00f593b5f550fe318ad2b170a02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:57:24 +0000 Subject: [PATCH 02/11] Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications - Add _deferralDepth and _pendingChange int fields for thread-safe deferral tracking - Update RaiseChanged() to defer notification when _deferralDepth > 0 - Add DeferChanges() public method returning a DeferralScope : IDisposable - Add EndDeferral() private method using Interlocked ops for atomicity - Add unit tests: no-mutation scope, single/multi-mutations, mixed add+remove, Clear, nested scopes, idempotent dispose, no-handler case, baseline behavior Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 72 ++++++- .../McpServerPrimitiveCollectionTests.cs | 191 ++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index e126fb13d..2b728bd95 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,6 +12,12 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; + /// Depth counter for active scopes. Positive means notifications are deferred. + private int _deferralDepth; + + /// Whether a change occurred while notifications were deferred. 1 means pending, 0 means none. + private int _pendingChange; + /// /// Initializes a new instance of the class. /// @@ -33,8 +39,72 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// Gets a value that indicates whether there are any primitives in the collection. public bool IsEmpty => _primitives.IsEmpty; + /// + /// Begins a deferred-change scope. notifications are suppressed + /// until the returned scope is disposed, at which point a single notification is raised + /// if any mutation occurred during the scope. Nesting is supported; the notification + /// fires when the outermost scope disposes. + /// + /// An that ends the deferral scope when disposed. + /// + /// Use this method to batch multiple mutations (add, remove, clear) into a single + /// notification: + /// + /// using (collection.DeferChanges()) + /// { + /// foreach (var tool in tools) + /// collection.TryAdd(tool); + /// } // one Changed notification fires here + /// + /// The scope is exception-safe: even if an exception is thrown inside the using block, + /// the deferral is ended on dispose. If any mutation occurred before the exception, a single + /// notification is raised. + /// + public IDisposable DeferChanges() + { + Interlocked.Increment(ref _deferralDepth); + return new DeferralScope(this); + } + /// Raises if there are registered handlers. - protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); + /// + /// If a scope is active, the notification is deferred until the + /// outermost scope is disposed. Derived types that override mutation methods and call + /// will automatically participate in deferral. + /// + protected void RaiseChanged() + { + if (Volatile.Read(ref _deferralDepth) > 0) + { + Interlocked.Exchange(ref _pendingChange, 1); + return; + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + private void EndDeferral() + { + if (Interlocked.Decrement(ref _deferralDepth) == 0 && + Interlocked.Exchange(ref _pendingChange, 0) == 1) + { + RaiseChanged(); + } + } + + private sealed class DeferralScope : IDisposable + { + private McpServerPrimitiveCollection? _collection; + + public DeferralScope(McpServerPrimitiveCollection collection) => + _collection = collection; + + public void Dispose() + { + McpServerPrimitiveCollection? collection = Interlocked.Exchange(ref _collection, null); + collection?.EndDeferral(); + } + } /// Gets the with the specified from the collection. /// The name of the primitive to retrieve. diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs new file mode 100644 index 000000000..db487cb59 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -0,0 +1,191 @@ +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerPrimitiveCollectionTests +{ + private static McpServerTool CreateTool(string name) => + McpServerTool.Create(() => name, new() { Name = name }); + + [Fact] + public void DeferChanges_NoMutation_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + // no mutations + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChanges_SingleMutation_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(0, changeCount); // not fired yet + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_MixedAddAndRemove_FiresOneChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool2")); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_WithClear_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.Clear(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_Nested_FiresOnceOnOutermostDispose() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); + + using (collection.DeferChanges()) + { + 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 DeferChanges_AfterScope_ResumesImmediateNotifications() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + 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 DeferChanges_DisposeIdempotent_DoesNotFireTwice() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope = collection.DeferChanges(); + 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 DeferChanges_ScopeWithNoHandlers_DoesNotThrow() + { + var collection = new McpServerPrimitiveCollection(); + // no Changed handler registered + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Single(collection); + } + + [Fact] + public void WithoutDeferChanges_EachMutationFiresImmediately() + { + var collection = new McpServerPrimitiveCollection(); + 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); + } +} From 116fd8d114804584278afe012ed5e027222cc136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:53:22 +0000 Subject: [PATCH 03/11] Address review feedback on McpServerPrimitiveCollection - Rename DeferralScope to ChangeDeferralScope - Add preexisting Changed behavior tests (TryAdd, Remove, Clear -- with and without success) - Add DeferChanges tests for duplicate TryAdd (with and without prior entry) - Add DeferChanges test for TryAdd-then-Remove same tool (fires one Changed even though net content is unchanged) Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 6 +- .../McpServerPrimitiveCollectionTests.cs | 167 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 2b728bd95..a907c9ab9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -63,7 +63,7 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul public IDisposable DeferChanges() { Interlocked.Increment(ref _deferralDepth); - return new DeferralScope(this); + return new ChangeDeferralScope(this); } /// Raises if there are registered handlers. @@ -92,11 +92,11 @@ private void EndDeferral() } } - private sealed class DeferralScope : IDisposable + private sealed class ChangeDeferralScope : IDisposable { private McpServerPrimitiveCollection? _collection; - public DeferralScope(McpServerPrimitiveCollection collection) => + public ChangeDeferralScope(McpServerPrimitiveCollection collection) => _collection = collection; public void Dispose() diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs index db487cb59..6528a3e24 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -7,6 +7,114 @@ public class McpServerPrimitiveCollectionTests private static McpServerTool CreateTool(string name) => McpServerTool.Create(() => name, new() { Name = name }); + // ------------------------------------------------------------------------- + // Preexisting behavior -- Changed event without DeferChanges + // ------------------------------------------------------------------------- + + [Fact] + public void TryAdd_NewTool_ReturnsTrue_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChanges -- basic deferral behavior + // ------------------------------------------------------------------------- + [Fact] public void DeferChanges_NoMutation_DoesNotFireChanged() { @@ -76,6 +184,65 @@ public void DeferChanges_MixedAddAndRemove_FiresOneChanged() Assert.Equal(1, changeCount); } + [Fact] + public void DeferChanges_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(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + var tool = CreateTool("tool1"); + collection.TryAdd(tool); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void DeferChanges_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() + { + // The first TryAdd succeeds (mutation), the second fails (no mutation). + // Exactly one Changed fires on dispose. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); // succeeds + collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_OnlyFailedTryAdds_DoesNotFireChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); // fails -- already present + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); + } + [Fact] public void DeferChanges_WithClear_FiresOneChanged() { From b2fb3a0c76c327b46c39c91a2f97d5ec241b6671 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:00:40 +0000 Subject: [PATCH 04/11] Address review feedback: rename comment section header Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollectionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs index 6528a3e24..7232d9881 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -8,7 +8,7 @@ private static McpServerTool CreateTool(string name) => McpServerTool.Create(() => name, new() { Name = name }); // ------------------------------------------------------------------------- - // Preexisting behavior -- Changed event without DeferChanges + // Changed event without DeferChanges // ------------------------------------------------------------------------- [Fact] From 9f57459be2999430a810a8532b08d12de77ae7c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:46:26 +0000 Subject: [PATCH 05/11] Address review feedback on DeferChanges concurrency and tests Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 42 ++++- .../McpServerBuilderExtensionsPromptsTests.cs | 45 +++++ ...cpServerBuilderExtensionsResourcesTests.cs | 45 +++++ .../McpServerBuilderExtensionsToolsTests.cs | 45 +++++ .../McpServerPrimitiveCollectionTests.cs | 175 ++++++++++++++++++ 5 files changed, 343 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index a907c9ab9..553026064 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,11 +12,14 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; + /// Lock protecting and . + private readonly object _deferralLock = new(); + /// Depth counter for active scopes. Positive means notifications are deferred. private int _deferralDepth; - /// Whether a change occurred while notifications were deferred. 1 means pending, 0 means none. - private int _pendingChange; + /// Whether a change occurred while notifications were deferred. + private bool _pendingChange; /// /// Initializes a new instance of the class. @@ -59,10 +62,19 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// The scope is exception-safe: even if an exception is thrown inside the using block, /// the deferral is ended on dispose. If any mutation occurred before the exception, a single /// notification is raised. + /// + /// Mutations from any thread during an open scope are coalesced. A single + /// notification fires on the thread that disposes the outermost 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. + /// /// public IDisposable DeferChanges() { - Interlocked.Increment(ref _deferralDepth); + lock (_deferralLock) + { + _deferralDepth++; + } return new ChangeDeferralScope(this); } @@ -74,10 +86,13 @@ public IDisposable DeferChanges() /// protected void RaiseChanged() { - if (Volatile.Read(ref _deferralDepth) > 0) + lock (_deferralLock) { - Interlocked.Exchange(ref _pendingChange, 1); - return; + if (_deferralDepth > 0) + { + _pendingChange = true; + return; + } } Changed?.Invoke(this, EventArgs.Empty); @@ -85,10 +100,19 @@ protected void RaiseChanged() private void EndDeferral() { - if (Interlocked.Decrement(ref _deferralDepth) == 0 && - Interlocked.Exchange(ref _pendingChange, 0) == 1) + bool raise; + lock (_deferralLock) { - RaiseChanged(); + raise = --_deferralDepth == 0 && _pendingChange; + if (raise) + { + _pendingChange = false; + } + } + + if (raise) + { + Changed?.Invoke(this, EventArgs.Empty); } } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 4182957cf..dc137bf83 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -173,6 +173,51 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt"); } + [Fact] + public async Task DeferChanges_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>().Value; + var serverPrompts = serverOptions.PromptCollection; + Assert.NotNull(serverPrompts); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(); + + await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(); + } + return default; + })) + { + using (serverPrompts.DeferChanges()) + { + 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() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d8fd0a231..6362479a4 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -207,6 +207,51 @@ public async Task Can_Be_Notified_Of_Resource_Changes() Assert.DoesNotContain(resources, t => t.Name == "NewResource"); } + [Fact] + public async Task DeferChanges_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>().Value; + var serverResources = serverOptions.ResourceCollection; + Assert.NotNull(serverResources); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(); + + await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(); + } + return default; + })) + { + using (serverResources.DeferChanges()) + { + 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() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5359ec73c..5c86e97ef 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -232,6 +232,51 @@ public async Task Can_Be_Notified_Of_Tool_Changes() Assert.DoesNotContain(tools, t => t.Name == "NewTool"); } + [Fact] + public async Task DeferChanges_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>().Value; + var serverTools = serverOptions.ToolCollection; + Assert.NotNull(serverTools); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(); + + await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(); + } + return default; + })) + { + using (serverTools.DeferChanges()) + { + 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() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs index 7232d9881..a0fc8d103 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.AI; using ModelContextProtocol.Server; namespace ModelContextProtocol.Tests.Server; @@ -7,6 +8,9 @@ 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 DeferChanges // ------------------------------------------------------------------------- @@ -355,4 +359,175 @@ public void WithoutDeferChanges_EachMutationFiresImmediately() collection.TryAdd(CreateTool("tool3")); Assert.Equal(3, changeCount); } + + // ------------------------------------------------------------------------- + // DeferChanges -- concurrency + // ------------------------------------------------------------------------- + + [Fact] + public async Task DeferChanges_ConcurrentMutations_FiresExactlyOneChanged() + { + const int threadCount = 10; + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + using (collection.DeferChanges()) + { + 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 DeferChanges_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(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + var scope = collection.DeferChanges(); + + // 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); + } + } + + // ------------------------------------------------------------------------- + // DeferChanges -- derived-type coalescing + // ------------------------------------------------------------------------- + + private sealed class TrackingCollection : McpServerPrimitiveCollection + { + public void RaiseChangedDirectly() => RaiseChanged(); + } + + [Fact] + public void DeferChanges_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.DeferChanges()) + { + collection.RaiseChangedDirectly(); + collection.RaiseChangedDirectly(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_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); + } + + // ------------------------------------------------------------------------- + // DeferChanges -- exception safety + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChanges_ExceptionDuringScope_StillFiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChanges()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChanges_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChanges()) + { + 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); + } + + // ------------------------------------------------------------------------- + // DeferChanges -- prompt collection coverage + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChanges_PromptCollection_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChanges()) + { + 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); + } } From ee157edd2404312694a50bed7dc2e6f298bcab71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:53:52 +0000 Subject: [PATCH 06/11] Apply review feedback renames: DeferChanges->DeferChangedEvents, _deferralDepth->_activeDeferralScopes Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 18 ++-- .../McpServerBuilderExtensionsPromptsTests.cs | 4 +- ...cpServerBuilderExtensionsResourcesTests.cs | 4 +- .../McpServerBuilderExtensionsToolsTests.cs | 4 +- .../McpServerPrimitiveCollectionTests.cs | 90 +++++++++---------- 5 files changed, 60 insertions(+), 60 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 553026064..63a69dcd8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,11 +12,11 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; - /// Lock protecting and . + /// Lock protecting and . private readonly object _deferralLock = new(); - /// Depth counter for active scopes. Positive means notifications are deferred. - private int _deferralDepth; + /// Depth counter for active scopes. Positive means notifications are deferred. + private int _activeDeferralScopes; /// Whether a change occurred while notifications were deferred. private bool _pendingChange; @@ -53,7 +53,7 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// Use this method to batch multiple mutations (add, remove, clear) into a single /// notification: /// - /// using (collection.DeferChanges()) + /// using (collection.DeferChangedEvents()) /// { /// foreach (var tool in tools) /// collection.TryAdd(tool); @@ -69,18 +69,18 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// concurrent mutations and concurrent scope disposal are both safe. /// /// - public IDisposable DeferChanges() + public IDisposable DeferChangedEvents() { lock (_deferralLock) { - _deferralDepth++; + _activeDeferralScopes++; } return new ChangeDeferralScope(this); } /// Raises if there are registered handlers. /// - /// If a scope is active, the notification is deferred until the + /// If a scope is active, the notification is deferred until the /// outermost scope is disposed. Derived types that override mutation methods and call /// will automatically participate in deferral. /// @@ -88,7 +88,7 @@ protected void RaiseChanged() { lock (_deferralLock) { - if (_deferralDepth > 0) + if (_activeDeferralScopes > 0) { _pendingChange = true; return; @@ -103,7 +103,7 @@ private void EndDeferral() bool raise; lock (_deferralLock) { - raise = --_deferralDepth == 0 && _pendingChange; + raise = --_activeDeferralScopes == 0 && _pendingChange; if (raise) { _pendingChange = false; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index dc137bf83..253f337d9 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -174,7 +174,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() } [Fact] - public async Task DeferChanges_BatchAddPrompts_EmitsExactlyOneNotification() + 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. @@ -199,7 +199,7 @@ public async Task DeferChanges_BatchAddPrompts_EmitsExactlyOneNotification() return default; })) { - using (serverPrompts.DeferChanges()) + using (serverPrompts.DeferChangedEvents()) { serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1")); serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2")); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 6362479a4..4de523c4a 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -208,7 +208,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes() } [Fact] - public async Task DeferChanges_BatchAddResources_EmitsExactlyOneNotification() + 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. @@ -233,7 +233,7 @@ public async Task DeferChanges_BatchAddResources_EmitsExactlyOneNotification() return default; })) { - using (serverResources.DeferChanges()) + 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")); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5c86e97ef..457df8f9b 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -233,7 +233,7 @@ public async Task Can_Be_Notified_Of_Tool_Changes() } [Fact] - public async Task DeferChanges_BatchAddTools_EmitsExactlyOneNotification() + 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. @@ -258,7 +258,7 @@ public async Task DeferChanges_BatchAddTools_EmitsExactlyOneNotification() return default; })) { - using (serverTools.DeferChanges()) + using (serverTools.DeferChangedEvents()) { serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1")); serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2")); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs index a0fc8d103..2807bec65 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -12,7 +12,7 @@ private static McpServerPrompt CreatePrompt(string name) => McpServerPrompt.Create(() => new ChatMessage(ChatRole.User, name), new() { Name = name }); // ------------------------------------------------------------------------- - // Changed event without DeferChanges + // Changed event without DeferChangedEvents // ------------------------------------------------------------------------- [Fact] @@ -116,17 +116,17 @@ public void Clear_EmptyCollection_FiresChanged() } // ------------------------------------------------------------------------- - // DeferChanges -- basic deferral behavior + // DeferChangedEvents -- basic deferral behavior // ------------------------------------------------------------------------- [Fact] - public void DeferChanges_NoMutation_DoesNotFireChanged() + public void DeferChangedEvents_NoMutation_DoesNotFireChanged() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { // no mutations } @@ -135,13 +135,13 @@ public void DeferChanges_NoMutation_DoesNotFireChanged() } [Fact] - public void DeferChanges_SingleMutation_FiresOneChanged() + public void DeferChangedEvents_SingleMutation_FiresOneChanged() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); Assert.Equal(0, changeCount); // not fired yet @@ -151,13 +151,13 @@ public void DeferChanges_SingleMutation_FiresOneChanged() } [Fact] - public void DeferChanges_MultipleMutations_FiresOneChanged() + public void DeferChangedEvents_MultipleMutations_FiresOneChanged() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); collection.TryAdd(CreateTool("tool2")); @@ -169,7 +169,7 @@ public void DeferChanges_MultipleMutations_FiresOneChanged() } [Fact] - public void DeferChanges_MixedAddAndRemove_FiresOneChanged() + public void DeferChangedEvents_MixedAddAndRemove_FiresOneChanged() { var tool = CreateTool("tool1"); var collection = new McpServerPrimitiveCollection(); @@ -178,7 +178,7 @@ public void DeferChanges_MixedAddAndRemove_FiresOneChanged() int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool2")); collection.Remove(tool); @@ -189,7 +189,7 @@ public void DeferChanges_MixedAddAndRemove_FiresOneChanged() } [Fact] - public void DeferChanges_AddThenRemoveSameTool_FiresOneChanged() + public void DeferChangedEvents_AddThenRemoveSameTool_FiresOneChanged() { // Net effect is no change in contents, but a Changed notification still fires // because mutations occurred during the scope. @@ -197,7 +197,7 @@ public void DeferChanges_AddThenRemoveSameTool_FiresOneChanged() int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { var tool = CreateTool("tool1"); collection.TryAdd(tool); @@ -210,7 +210,7 @@ public void DeferChanges_AddThenRemoveSameTool_FiresOneChanged() } [Fact] - public void DeferChanges_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() + public void DeferChangedEvents_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() { // The first TryAdd succeeds (mutation), the second fails (no mutation). // Exactly one Changed fires on dispose. @@ -218,7 +218,7 @@ public void DeferChanges_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); // succeeds collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name @@ -229,7 +229,7 @@ public void DeferChanges_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() } [Fact] - public void DeferChanges_OnlyFailedTryAdds_DoesNotFireChanged() + public void DeferChangedEvents_OnlyFailedTryAdds_DoesNotFireChanged() { var tool = CreateTool("tool1"); var collection = new McpServerPrimitiveCollection(); @@ -238,7 +238,7 @@ public void DeferChanges_OnlyFailedTryAdds_DoesNotFireChanged() int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); // fails -- already present Assert.Equal(0, changeCount); @@ -248,7 +248,7 @@ public void DeferChanges_OnlyFailedTryAdds_DoesNotFireChanged() } [Fact] - public void DeferChanges_WithClear_FiresOneChanged() + public void DeferChangedEvents_WithClear_FiresOneChanged() { var collection = new McpServerPrimitiveCollection(); collection.TryAdd(CreateTool("tool1")); @@ -257,7 +257,7 @@ public void DeferChanges_WithClear_FiresOneChanged() int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.Clear(); Assert.Equal(0, changeCount); @@ -267,17 +267,17 @@ public void DeferChanges_WithClear_FiresOneChanged() } [Fact] - public void DeferChanges_Nested_FiresOnceOnOutermostDispose() + public void DeferChangedEvents_Nested_FiresOnceOnOutermostDispose() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool2")); Assert.Equal(0, changeCount); @@ -291,13 +291,13 @@ public void DeferChanges_Nested_FiresOnceOnOutermostDispose() } [Fact] - public void DeferChanges_AfterScope_ResumesImmediateNotifications() + public void DeferChangedEvents_AfterScope_ResumesImmediateNotifications() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); } @@ -313,13 +313,13 @@ public void DeferChanges_AfterScope_ResumesImmediateNotifications() } [Fact] - public void DeferChanges_DisposeIdempotent_DoesNotFireTwice() + public void DeferChangedEvents_DisposeIdempotent_DoesNotFireTwice() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - var scope = collection.DeferChanges(); + var scope = collection.DeferChangedEvents(); collection.TryAdd(CreateTool("tool1")); scope.Dispose(); @@ -330,12 +330,12 @@ public void DeferChanges_DisposeIdempotent_DoesNotFireTwice() } [Fact] - public void DeferChanges_ScopeWithNoHandlers_DoesNotThrow() + public void DeferChangedEvents_ScopeWithNoHandlers_DoesNotThrow() { var collection = new McpServerPrimitiveCollection(); // no Changed handler registered - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); } @@ -344,7 +344,7 @@ public void DeferChanges_ScopeWithNoHandlers_DoesNotThrow() } [Fact] - public void WithoutDeferChanges_EachMutationFiresImmediately() + public void WithoutDeferChangedEvents_EachMutationFiresImmediately() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; @@ -361,18 +361,18 @@ public void WithoutDeferChanges_EachMutationFiresImmediately() } // ------------------------------------------------------------------------- - // DeferChanges -- concurrency + // DeferChangedEvents -- concurrency // ------------------------------------------------------------------------- [Fact] - public async Task DeferChanges_ConcurrentMutations_FiresExactlyOneChanged() + public async Task DeferChangedEvents_ConcurrentMutations_FiresExactlyOneChanged() { const int threadCount = 10; var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i => Task.Run(() => collection.TryAdd(CreateTool($"tool{i}")), TestContext.Current.CancellationToken))); @@ -383,7 +383,7 @@ await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i => } [Fact] - public async Task DeferChanges_MutationRacingWithDispose_NotificationNotLost() + 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 @@ -395,7 +395,7 @@ public async Task DeferChanges_MutationRacingWithDispose_NotificationNotLost() int changeCount = 0; collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); - var scope = collection.DeferChanges(); + var scope = collection.DeferChangedEvents(); // Run the mutation and the dispose concurrently. var addTask = Task.Run(() => collection.TryAdd(CreateTool("tool1")), TestContext.Current.CancellationToken); @@ -415,7 +415,7 @@ public async Task DeferChanges_MutationRacingWithDispose_NotificationNotLost() } // ------------------------------------------------------------------------- - // DeferChanges -- derived-type coalescing + // DeferChangedEvents -- derived-type coalescing // ------------------------------------------------------------------------- private sealed class TrackingCollection : McpServerPrimitiveCollection @@ -424,7 +424,7 @@ private sealed class TrackingCollection : McpServerPrimitiveCollection changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.RaiseChangedDirectly(); collection.RaiseChangedDirectly(); @@ -444,7 +444,7 @@ public void DeferChanges_DerivedTypeCallsRaiseChanged_Coalesces() } [Fact] - public void DeferChanges_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately() + public void DeferChangedEvents_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately() { var collection = new TrackingCollection(); int changeCount = 0; @@ -458,11 +458,11 @@ public void DeferChanges_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately( } // ------------------------------------------------------------------------- - // DeferChanges -- exception safety + // DeferChangedEvents -- exception safety // ------------------------------------------------------------------------- [Fact] - public void DeferChanges_ExceptionDuringScope_StillFiresChanged() + public void DeferChangedEvents_ExceptionDuringScope_StillFiresChanged() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; @@ -470,7 +470,7 @@ public void DeferChanges_ExceptionDuringScope_StillFiresChanged() try { - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); throw new InvalidOperationException("test"); @@ -482,7 +482,7 @@ public void DeferChanges_ExceptionDuringScope_StillFiresChanged() } [Fact] - public void DeferChanges_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() + public void DeferChangedEvents_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; @@ -490,7 +490,7 @@ public void DeferChanges_ExceptionDuringScope_ResumesImmediateNotificationsAfter try { - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreateTool("tool1")); throw new InvalidOperationException("test"); @@ -509,17 +509,17 @@ public void DeferChanges_ExceptionDuringScope_ResumesImmediateNotificationsAfter } // ------------------------------------------------------------------------- - // DeferChanges -- prompt collection coverage + // DeferChangedEvents -- prompt collection coverage // ------------------------------------------------------------------------- [Fact] - public void DeferChanges_PromptCollection_MultipleMutations_FiresOneChanged() + public void DeferChangedEvents_PromptCollection_MultipleMutations_FiresOneChanged() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; collection.Changed += (_, _) => changeCount++; - using (collection.DeferChanges()) + using (collection.DeferChangedEvents()) { collection.TryAdd(CreatePrompt("prompt1")); collection.TryAdd(CreatePrompt("prompt2")); From 6a146f08c0195d46c87fe12bab9bac9f0cebf80f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:13:30 +0000 Subject: [PATCH 07/11] Rename _pendingChange to _hasDeferredChangeEvents Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 63a69dcd8..316886aef 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,14 +12,14 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; - /// Lock protecting and . + /// Lock protecting and . private readonly object _deferralLock = new(); /// Depth counter for active scopes. Positive means notifications are deferred. private int _activeDeferralScopes; /// Whether a change occurred while notifications were deferred. - private bool _pendingChange; + private bool _hasDeferredChangeEvents; /// /// Initializes a new instance of the class. @@ -90,7 +90,7 @@ protected void RaiseChanged() { if (_activeDeferralScopes > 0) { - _pendingChange = true; + _hasDeferredChangeEvents = true; return; } } @@ -103,10 +103,10 @@ private void EndDeferral() bool raise; lock (_deferralLock) { - raise = --_activeDeferralScopes == 0 && _pendingChange; + raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents; if (raise) { - _pendingChange = false; + _hasDeferredChangeEvents = false; } } From b59f3a08c021f9b38748fd0c396c05666b3cf347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:20:00 +0000 Subject: [PATCH 08/11] Remove code sample from DeferChangedEvents remarks; fix "the" -> "a" in exception-safety sentence Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 316886aef..43a401304 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -50,16 +50,7 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// /// An that ends the deferral scope when disposed. /// - /// Use this method to batch multiple mutations (add, remove, clear) into a single - /// notification: - /// - /// using (collection.DeferChangedEvents()) - /// { - /// foreach (var tool in tools) - /// collection.TryAdd(tool); - /// } // one Changed notification fires here - /// - /// The scope is exception-safe: even if an exception is thrown inside the using block, + /// The scope is exception-safe: even if an exception is thrown inside a using block, /// the deferral is ended on dispose. If any mutation occurred before the exception, a single /// notification is raised. /// From 4bf5e78968bd2cf0db5c1b7447c946bdbab6e377 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:34:20 +0000 Subject: [PATCH 09/11] Add out-of-order and double-dispose tests; update docs to remove outermost phrasing Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 13 ++-- .../McpServerPrimitiveCollectionTests.cs | 64 ++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 43a401304..aa281989b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -45,8 +45,8 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// /// Begins a deferred-change scope. notifications are suppressed /// until the returned scope is disposed, at which point a single notification is raised - /// if any mutation occurred during the scope. Nesting is supported; the notification - /// fires when the outermost scope disposes. + /// if any mutation occurred during the scope. Multiple scopes may be active simultaneously; + /// the notification fires once all active scopes have been disposed. /// /// An that ends the deferral scope when disposed. /// @@ -55,9 +55,10 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// notification is raised. /// /// Mutations from any thread during an open scope are coalesced. A single - /// notification fires on the thread that disposes the outermost scope, only if at least one + /// 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. + /// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope + /// instance more than once is safe and has no additional effect. /// /// public IDisposable DeferChangedEvents() @@ -71,8 +72,8 @@ public IDisposable DeferChangedEvents() /// Raises if there are registered handlers. /// - /// If a scope is active, the notification is deferred until the - /// outermost scope is disposed. Derived types that override mutation methods and call + /// If a scope is active, the notification is deferred until all + /// active scopes are disposed. Derived types that override mutation methods and call /// will automatically participate in deferral. /// protected void RaiseChanged() diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs index 2807bec65..7acac660e 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -267,7 +267,7 @@ public void DeferChangedEvents_WithClear_FiresOneChanged() } [Fact] - public void DeferChangedEvents_Nested_FiresOnceOnOutermostDispose() + public void DeferChangedEvents_Nested_FiresOnceWhenAllScopesDisposed() { var collection = new McpServerPrimitiveCollection(); int changeCount = 0; @@ -290,6 +290,68 @@ public void DeferChangedEvents_Nested_FiresOnceOnOutermostDispose() 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(); + 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(); + 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(); + 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() { From 7427d11142e734fc0a28f068a4ee17391eddbcb3 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Sat, 4 Jul 2026 00:08:18 -0400 Subject: [PATCH 10/11] Use generic TaskCompletionSource in batched change-notification tests The non-generic TaskCompletionSource is .NET 5+ only and fails to compile under net472, which ModelContextProtocol.Tests targets. Switch to the generic form in the DeferChangedEvents batch tests for tools, prompts, and resources: new TaskCompletionSource() becomes new TaskCompletionSource() and TrySetResult() becomes TrySetResult(true). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/McpServerBuilderExtensionsPromptsTests.cs | 4 ++-- .../Configuration/McpServerBuilderExtensionsResourcesTests.cs | 4 ++-- .../Configuration/McpServerBuilderExtensionsToolsTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 253f337d9..6272a9559 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -188,13 +188,13 @@ public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification Assert.NotNull(serverPrompts); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); + var firstNotification = new TaskCompletionSource(); await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1) { - firstNotification.TrySetResult(); + firstNotification.TrySetResult(true); } return default; })) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 4de523c4a..a0145a39b 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -222,13 +222,13 @@ public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotificati Assert.NotNull(serverResources); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); + var firstNotification = new TaskCompletionSource(); await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1) { - firstNotification.TrySetResult(); + firstNotification.TrySetResult(true); } return default; })) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 457df8f9b..8af1e0460 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -247,13 +247,13 @@ public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() Assert.NotNull(serverTools); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); + var firstNotification = new TaskCompletionSource(); await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1) { - firstNotification.TrySetResult(); + firstNotification.TrySetResult(true); } return default; })) From 628ef359fae649961ab3fa6ef9c094e7624fafc8 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Sat, 4 Jul 2026 00:50:47 -0400 Subject: [PATCH 11/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Configuration/McpServerBuilderExtensionsPromptsTests.cs | 3 +-- .../Configuration/McpServerBuilderExtensionsResourcesTests.cs | 3 +-- .../Configuration/McpServerBuilderExtensionsToolsTests.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 6272a9559..03234f6e3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -188,8 +188,7 @@ public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification Assert.NotNull(serverPrompts); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); - + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index a0145a39b..663c06a7f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -222,8 +222,7 @@ public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotificati Assert.NotNull(serverResources); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); - + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 8af1e0460..154c40f94 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -247,8 +247,7 @@ public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() Assert.NotNull(serverTools); int notificationCount = 0; - var firstNotification = new TaskCompletionSource(); - + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { if (Interlocked.Increment(ref notificationCount) == 1)