Skip to content

Commit 9f57459

Browse files
Copilotjeffhandley
andauthored
Address review feedback on DeferChanges concurrency and tests
Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
1 parent b2fb3a0 commit 9f57459

5 files changed

Lines changed: 343 additions & 9 deletions

File tree

src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
1212
/// <summary>Concurrent dictionary of primitives, indexed by their names.</summary>
1313
private readonly ConcurrentDictionary<string, T> _primitives;
1414

15+
/// <summary>Lock protecting <see cref="_deferralDepth"/> and <see cref="_pendingChange"/>.</summary>
16+
private readonly object _deferralLock = new();
17+
1518
/// <summary>Depth counter for active <see cref="DeferChanges"/> scopes. Positive means notifications are deferred.</summary>
1619
private int _deferralDepth;
1720

18-
/// <summary>Whether a change occurred while notifications were deferred. 1 means pending, 0 means none.</summary>
19-
private int _pendingChange;
21+
/// <summary>Whether a change occurred while notifications were deferred.</summary>
22+
private bool _pendingChange;
2023

2124
/// <summary>
2225
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
@@ -59,10 +62,19 @@ public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = nul
5962
/// The scope is exception-safe: even if an exception is thrown inside the <c>using</c> block,
6063
/// the deferral is ended on dispose. If any mutation occurred before the exception, a single
6164
/// <see cref="Changed"/> notification is raised.
65+
/// <para>
66+
/// Mutations from any thread during an open scope are coalesced. A single <see cref="Changed"/>
67+
/// notification fires on the thread that disposes the outermost scope, only if at least one
68+
/// mutation occurred. All deferral state transitions are guarded by an internal lock, so
69+
/// concurrent mutations and concurrent scope disposal are both safe.
70+
/// </para>
6271
/// </remarks>
6372
public IDisposable DeferChanges()
6473
{
65-
Interlocked.Increment(ref _deferralDepth);
74+
lock (_deferralLock)
75+
{
76+
_deferralDepth++;
77+
}
6678
return new ChangeDeferralScope(this);
6779
}
6880

@@ -74,21 +86,33 @@ public IDisposable DeferChanges()
7486
/// </remarks>
7587
protected void RaiseChanged()
7688
{
77-
if (Volatile.Read(ref _deferralDepth) > 0)
89+
lock (_deferralLock)
7890
{
79-
Interlocked.Exchange(ref _pendingChange, 1);
80-
return;
91+
if (_deferralDepth > 0)
92+
{
93+
_pendingChange = true;
94+
return;
95+
}
8196
}
8297

8398
Changed?.Invoke(this, EventArgs.Empty);
8499
}
85100

86101
private void EndDeferral()
87102
{
88-
if (Interlocked.Decrement(ref _deferralDepth) == 0 &&
89-
Interlocked.Exchange(ref _pendingChange, 0) == 1)
103+
bool raise;
104+
lock (_deferralLock)
90105
{
91-
RaiseChanged();
106+
raise = --_deferralDepth == 0 && _pendingChange;
107+
if (raise)
108+
{
109+
_pendingChange = false;
110+
}
111+
}
112+
113+
if (raise)
114+
{
115+
Changed?.Invoke(this, EventArgs.Empty);
92116
}
93117
}
94118

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,51 @@ public async Task Can_Be_Notified_Of_Prompt_Changes()
173173
Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt");
174174
}
175175

176+
[Fact]
177+
public async Task DeferChanges_BatchAddPrompts_EmitsExactlyOneNotification()
178+
{
179+
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
180+
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
181+
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
182+
{
183+
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
184+
});
185+
186+
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
187+
var serverPrompts = serverOptions.PromptCollection;
188+
Assert.NotNull(serverPrompts);
189+
190+
int notificationCount = 0;
191+
var firstNotification = new TaskCompletionSource();
192+
193+
await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) =>
194+
{
195+
if (Interlocked.Increment(ref notificationCount) == 1)
196+
{
197+
firstNotification.TrySetResult();
198+
}
199+
return default;
200+
}))
201+
{
202+
using (serverPrompts.DeferChanges())
203+
{
204+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1"));
205+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2"));
206+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3"));
207+
}
208+
209+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
210+
211+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
212+
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
213+
Assert.Contains(prompts, t => t.Name == "BatchPrompt1");
214+
Assert.Contains(prompts, t => t.Name == "BatchPrompt2");
215+
Assert.Contains(prompts, t => t.Name == "BatchPrompt3");
216+
217+
Assert.Equal(1, notificationCount);
218+
}
219+
}
220+
176221
[Fact]
177222
public async Task AttributeProperties_Propagated()
178223
{

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,51 @@ public async Task Can_Be_Notified_Of_Resource_Changes()
207207
Assert.DoesNotContain(resources, t => t.Name == "NewResource");
208208
}
209209

210+
[Fact]
211+
public async Task DeferChanges_BatchAddResources_EmitsExactlyOneNotification()
212+
{
213+
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
214+
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
215+
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
216+
{
217+
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
218+
});
219+
220+
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
221+
var serverResources = serverOptions.ResourceCollection;
222+
Assert.NotNull(serverResources);
223+
224+
int notificationCount = 0;
225+
var firstNotification = new TaskCompletionSource();
226+
227+
await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) =>
228+
{
229+
if (Interlocked.Increment(ref notificationCount) == 1)
230+
{
231+
firstNotification.TrySetResult();
232+
}
233+
return default;
234+
}))
235+
{
236+
using (serverResources.DeferChanges())
237+
{
238+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1"));
239+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2"));
240+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3"));
241+
}
242+
243+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
244+
245+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
246+
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
247+
Assert.Contains(resources, t => t.Name == "BatchResource1");
248+
Assert.Contains(resources, t => t.Name == "BatchResource2");
249+
Assert.Contains(resources, t => t.Name == "BatchResource3");
250+
251+
Assert.Equal(1, notificationCount);
252+
}
253+
}
254+
210255
[Fact]
211256
public async Task AttributeProperties_Propagated()
212257
{

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,51 @@ public async Task Can_Be_Notified_Of_Tool_Changes()
232232
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
233233
}
234234

235+
[Fact]
236+
public async Task DeferChanges_BatchAddTools_EmitsExactlyOneNotification()
237+
{
238+
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
239+
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
240+
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
241+
{
242+
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
243+
});
244+
245+
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
246+
var serverTools = serverOptions.ToolCollection;
247+
Assert.NotNull(serverTools);
248+
249+
int notificationCount = 0;
250+
var firstNotification = new TaskCompletionSource();
251+
252+
await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
253+
{
254+
if (Interlocked.Increment(ref notificationCount) == 1)
255+
{
256+
firstNotification.TrySetResult();
257+
}
258+
return default;
259+
}))
260+
{
261+
using (serverTools.DeferChanges())
262+
{
263+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1"));
264+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2"));
265+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3"));
266+
}
267+
268+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
269+
270+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
271+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
272+
Assert.Contains(tools, t => t.Name == "BatchTool1");
273+
Assert.Contains(tools, t => t.Name == "BatchTool2");
274+
Assert.Contains(tools, t => t.Name == "BatchTool3");
275+
276+
Assert.Equal(1, notificationCount);
277+
}
278+
}
279+
235280
[Fact]
236281
public async Task Can_Call_Registered_Tool()
237282
{

0 commit comments

Comments
 (0)