Skip to content

Commit 84eb694

Browse files
CopilotjeffhandleyCopilotCopilot
authored
Add DeferChangedEvents() to McpServerPrimitiveCollection for batched change notifications (modelcontextprotocol#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>
1 parent 1214556 commit 84eb694

5 files changed

Lines changed: 814 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ 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="_activeDeferralScopes"/> and <see cref="_hasDeferredChangeEvents"/>.</summary>
16+
private readonly object _deferralLock = new();
17+
18+
/// <summary>Depth counter for active <see cref="DeferChangedEvents"/> scopes. Positive means notifications are deferred.</summary>
19+
private int _activeDeferralScopes;
20+
21+
/// <summary>Whether a change occurred while notifications were deferred.</summary>
22+
private bool _hasDeferredChangeEvents;
23+
1524
/// <summary>
1625
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
1726
/// </summary>
@@ -33,8 +42,85 @@ public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = nul
3342
/// <summary>Gets a value that indicates whether there are any primitives in the collection.</summary>
3443
public bool IsEmpty => _primitives.IsEmpty;
3544

45+
/// <summary>
46+
/// Begins a deferred-change scope. <see cref="Changed"/> notifications are suppressed
47+
/// until the returned scope is disposed, at which point a single notification is raised
48+
/// if any mutation occurred during the scope. Multiple scopes may be active simultaneously;
49+
/// the notification fires once all active scopes have been disposed.
50+
/// </summary>
51+
/// <returns>An <see cref="IDisposable"/> that ends the deferral scope when disposed.</returns>
52+
/// <remarks>
53+
/// The scope is exception-safe: even if an exception is thrown inside a <c>using</c> block,
54+
/// the deferral is ended on dispose. If any mutation occurred before the exception, a single
55+
/// <see cref="Changed"/> notification is raised.
56+
/// <para>
57+
/// Mutations from any thread during an open scope are coalesced. A single <see cref="Changed"/>
58+
/// notification fires on the thread that disposes the last active scope, only if at least one
59+
/// mutation occurred. All deferral state transitions are guarded by an internal lock, so
60+
/// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope
61+
/// instance more than once is safe and has no additional effect.
62+
/// </para>
63+
/// </remarks>
64+
public IDisposable DeferChangedEvents()
65+
{
66+
lock (_deferralLock)
67+
{
68+
_activeDeferralScopes++;
69+
}
70+
return new ChangeDeferralScope(this);
71+
}
72+
3673
/// <summary>Raises <see cref="Changed"/> if there are registered handlers.</summary>
37-
protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
74+
/// <remarks>
75+
/// If a <see cref="DeferChangedEvents"/> scope is active, the notification is deferred until all
76+
/// active scopes are disposed. Derived types that override mutation methods and call
77+
/// <see cref="RaiseChanged"/> will automatically participate in deferral.
78+
/// </remarks>
79+
protected void RaiseChanged()
80+
{
81+
lock (_deferralLock)
82+
{
83+
if (_activeDeferralScopes > 0)
84+
{
85+
_hasDeferredChangeEvents = true;
86+
return;
87+
}
88+
}
89+
90+
Changed?.Invoke(this, EventArgs.Empty);
91+
}
92+
93+
private void EndDeferral()
94+
{
95+
bool raise;
96+
lock (_deferralLock)
97+
{
98+
raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents;
99+
if (raise)
100+
{
101+
_hasDeferredChangeEvents = false;
102+
}
103+
}
104+
105+
if (raise)
106+
{
107+
Changed?.Invoke(this, EventArgs.Empty);
108+
}
109+
}
110+
111+
private sealed class ChangeDeferralScope : IDisposable
112+
{
113+
private McpServerPrimitiveCollection<T>? _collection;
114+
115+
public ChangeDeferralScope(McpServerPrimitiveCollection<T> collection) =>
116+
_collection = collection;
117+
118+
public void Dispose()
119+
{
120+
McpServerPrimitiveCollection<T>? collection = Interlocked.Exchange(ref _collection, null);
121+
collection?.EndDeferral();
122+
}
123+
}
38124

39125
/// <summary>Gets the <typeparamref name="T"/> with the specified <paramref name="name"/> from the collection.</summary>
40126
/// <param name="name">The name of the primitive to retrieve.</param>

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,50 @@ 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 DeferChangedEvents_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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
192+
await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) =>
193+
{
194+
if (Interlocked.Increment(ref notificationCount) == 1)
195+
{
196+
firstNotification.TrySetResult(true);
197+
}
198+
return default;
199+
}))
200+
{
201+
using (serverPrompts.DeferChangedEvents())
202+
{
203+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1"));
204+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2"));
205+
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3"));
206+
}
207+
208+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
209+
210+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
211+
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
212+
Assert.Contains(prompts, t => t.Name == "BatchPrompt1");
213+
Assert.Contains(prompts, t => t.Name == "BatchPrompt2");
214+
Assert.Contains(prompts, t => t.Name == "BatchPrompt3");
215+
216+
Assert.Equal(1, notificationCount);
217+
}
218+
}
219+
176220
[Fact]
177221
public async Task AttributeProperties_Propagated()
178222
{

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,50 @@ 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 DeferChangedEvents_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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
226+
await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) =>
227+
{
228+
if (Interlocked.Increment(ref notificationCount) == 1)
229+
{
230+
firstNotification.TrySetResult(true);
231+
}
232+
return default;
233+
}))
234+
{
235+
using (serverResources.DeferChangedEvents())
236+
{
237+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1"));
238+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2"));
239+
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3"));
240+
}
241+
242+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
243+
244+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
245+
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
246+
Assert.Contains(resources, t => t.Name == "BatchResource1");
247+
Assert.Contains(resources, t => t.Name == "BatchResource2");
248+
Assert.Contains(resources, t => t.Name == "BatchResource3");
249+
250+
Assert.Equal(1, notificationCount);
251+
}
252+
}
253+
210254
[Fact]
211255
public async Task AttributeProperties_Propagated()
212256
{

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,50 @@ 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 DeferChangedEvents_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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
251+
await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
252+
{
253+
if (Interlocked.Increment(ref notificationCount) == 1)
254+
{
255+
firstNotification.TrySetResult(true);
256+
}
257+
return default;
258+
}))
259+
{
260+
using (serverTools.DeferChangedEvents())
261+
{
262+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1"));
263+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2"));
264+
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3"));
265+
}
266+
267+
await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);
268+
269+
// Do a round-trip so that any second (erroneous) notification has time to arrive.
270+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
271+
Assert.Contains(tools, t => t.Name == "BatchTool1");
272+
Assert.Contains(tools, t => t.Name == "BatchTool2");
273+
Assert.Contains(tools, t => t.Name == "BatchTool3");
274+
275+
Assert.Equal(1, notificationCount);
276+
}
277+
}
278+
235279
[Fact]
236280
public async Task Can_Call_Registered_Tool()
237281
{

0 commit comments

Comments
 (0)