Background — the scenario that motivated this
While building nesm, a WebAssembly runtime that exposes itself as an MCP server, the server adopted progressive disclosure: it starts with ~12 entry tools and dynamically registers additional tool groups (Core, Memory, Component, Dwarf, Debug, …) as the client loads modules or opens debug sessions. Registering a group means adding a batch of ~2-16 tools to options.ToolCollection in a single logical operation.
We hit a rough edge in the SDK: McpServerPrimitiveCollection<T> raises its Changed event — and therefore the SDK emits one notifications/tools/list_changed wire message — after every individual TryAdd/Remove. A single "load module" operation that registers 16 tools produces 16 tools/list_changed notifications instead of one.
This is wasteful (16× the notification traffic per operation), and it is a plausible way to trip MCP clients that debounce or rate-limit list_changed — the client may coalesce the burst and drop all but the first, or throttle and miss the settled state. We debugged a client-side "additions not surfaced" symptom and, while the root cause turned out to be client-side, the notification storm was a real contributing hazard we wanted to eliminate on the server side.
The workaround, and why the SDK should own this
We fixed it in nesm (Blazor-Playground/nesm#61) with a BatchingToolCollection : McpServerPrimitiveCollection<McpServerTool> that exposes BeginBatch() returning an IDisposable; mutations inside the scope defer the Changed event and fire exactly one on dispose.
Making that subclass correct was harder than it should be, because the collection is only partially overridable:
TryAdd, Remove, Clear, TryGetPrimitive, Contains, PrimitiveNames, ToArray, CopyTo, GetEnumerator are virtual.
Count, IsEmpty, and the this[string] indexer are non-virtual and read the base type's private _primitives field.
RaiseChanged() is protected, but there is no supported way to suspend it around a batch.
Because we can't intercept the non-virtual members, our subclass has to bring its own backing store and override every virtual member so the base's private store is never consulted — yet a caller holding a base-typed McpServerPrimitiveCollection<T> reference still sees Count == 0 / empty indexer. That's a latent correctness trap for a workaround whose only goal is "send one notification instead of N." This is a common pattern (progressive disclosure / bulk registration), and every server that does it will need the same workaround. It belongs in the SDK.
Proposed API
Add a first-class batching primitive to McpServerPrimitiveCollection<T> that coalesces mutations into a single Changed:
public partial class McpServerPrimitiveCollection<T>
{
/// <summary>
/// Begins a deferred-change scope. <see cref="Changed"/> notifications are suppressed
/// until the returned scope is disposed, at which point a single notification is raised
/// if any mutation occurred during the scope. Nesting is supported; the notification
/// fires when the outermost scope disposes.
/// </summary>
public IDisposable DeferChanges();
}
(Alternate names for API review: BeginBatch(), BeginChangeBatch(). DeferChanges() deliberately echoes WPF's ICollectionView.DeferRefresh(), see Prior art.)
Semantics:
- Reentrant / nestable via a suppression depth; the coalesced
Changed fires only when the outermost scope disposes, and only if ≥1 mutation actually occurred.
- No change fires if the scope is empty or no mutation occurred.
- Thread-safe suspend/resume.
Example (what the nesm registrar would become):
using (options.ToolCollection.DeferChanges())
{
foreach (var tool in group.Tools)
options.ToolCollection.TryAdd(tool);
} // one notifications/tools/list_changed here
Alternatives considered
TryAddRange(IEnumerable<T>) / bulk-mutation methods. We considered range APIs that fire one Changed per call. Rejected: a range method only coalesces a single kind of mutation. Progressive disclosure frequently does adds and removes in one logical operation (e.g. swapping a module's feature set), and a range method leaves that gap — you'd be back to ≥2 notifications, or forced into a Reset. A deferral scope coalesces any mix of TryAdd/Remove/Clear into one notification. This mirrors why ObservableCollection<T> deliberately has no AddRange — NotifyCollectionChangedEventArgs can't express arbitrary batched mutations and consumers fall back to Reset (see dotnet/runtime#585).
- A
bool suspend flag (à la BindingList<T>.RaiseListChangedEvents). Rejected: no auto-restore (leaks the suspended state if an exception skips the reset), not nestable, and forces a manual "fire the coalesced event" call afterward. An IDisposable scope is exception-safe and composes.
Prior art in .NET
The exact shape we want — an IDisposable scope that suspends change notifications and fires one on dispose — is already proven in the framework by WPF's ICollectionView.DeferRefresh(), which returns an IDisposable that holds off CollectionChanged/refresh until the using block exits. That's the clean, ergonomic model this proposal follows (and the reason DeferChanges() is the suggested name).
Within dotnet/runtime the two halves of that idea are well-established, just never combined into one primitive:
IDisposable scope with a nesting counter — ObservableCollection<T>.BlockReentrancy() (System.ObjectModel), depth-counted via _blockReentrancyCount. (Used there for reentrancy guarding rather than batching, but it's the same scope mechanism.)
- Suspend-notifications-during-bulk-mutation —
BindingList<T>.RaiseListChangedEvents + ResetBindings() (System.ComponentModel.TypeConverter); DataTable.BeginLoadData()/EndLoadData() (System.Data.Common), which suspends notifications during bulk load; and the standardized ISupportInitialize.BeginInit()/EndInit() (System.ComponentModel.Primitives) deferral pattern.
Notably, where a framework shipped only a bool toggle, consumers repeatedly reinvent the disposable wrapper: EF Core exposes just ChangeTracker.AutoDetectChangesEnabled, and the community routinely hand-rolls an AutoDetectChangesOffScope : IDisposable around it. Shipping the scope in the SDK avoids that recurring boilerplate — which is exactly the BatchingToolCollection situation above.
Secondary ask (optional)
If DeferChanges() lands, servers no longer need to subclass just to batch. But to make subclassing safe in general, consider either making Count/IsEmpty/this[string] virtual, or documenting that a subclass overriding the storage members must also shadow these three. The batching API is the priority; this is a note for API review.
Compatibility
- Purely additive; no behavior change for existing callers who never call
DeferChanges().
- Applies uniformly to tools, prompts, and resources since they share
McpServerPrimitiveCollection<T>.
References
- nesm workaround:
src/Nesm.Mcp/BatchingToolCollection.cs and PR Blazor-Playground/nesm#61
- Prior art: WPF
ICollectionView.DeferRefresh(); ObservableCollection<T>.BlockReentrancy(), BindingList<T>.RaiseListChangedEvents, DataTable.BeginLoadData, ISupportInitialize (all dotnet/runtime); ObservableCollection range-notification rejection dotnet/runtime#585.
- Observed against
ModelContextProtocol 1.4.0.
CC @lewing
Background — the scenario that motivated this
While building nesm, a WebAssembly runtime that exposes itself as an MCP server, the server adopted progressive disclosure: it starts with ~12 entry tools and dynamically registers additional tool groups (Core, Memory, Component, Dwarf, Debug, …) as the client loads modules or opens debug sessions. Registering a group means adding a batch of ~2-16 tools to
options.ToolCollectionin a single logical operation.We hit a rough edge in the SDK:
McpServerPrimitiveCollection<T>raises itsChangedevent — and therefore the SDK emits onenotifications/tools/list_changedwire message — after every individualTryAdd/Remove. A single "load module" operation that registers 16 tools produces 16tools/list_changednotifications instead of one.This is wasteful (16× the notification traffic per operation), and it is a plausible way to trip MCP clients that debounce or rate-limit
list_changed— the client may coalesce the burst and drop all but the first, or throttle and miss the settled state. We debugged a client-side "additions not surfaced" symptom and, while the root cause turned out to be client-side, the notification storm was a real contributing hazard we wanted to eliminate on the server side.The workaround, and why the SDK should own this
We fixed it in nesm (Blazor-Playground/nesm#61) with a
BatchingToolCollection : McpServerPrimitiveCollection<McpServerTool>that exposesBeginBatch()returning anIDisposable; mutations inside the scope defer theChangedevent and fire exactly one on dispose.Making that subclass correct was harder than it should be, because the collection is only partially overridable:
TryAdd,Remove,Clear,TryGetPrimitive,Contains,PrimitiveNames,ToArray,CopyTo,GetEnumeratorare virtual.Count,IsEmpty, and thethis[string]indexer are non-virtual and read the base type's private_primitivesfield.RaiseChanged()isprotected, but there is no supported way to suspend it around a batch.Because we can't intercept the non-virtual members, our subclass has to bring its own backing store and override every virtual member so the base's private store is never consulted — yet a caller holding a base-typed
McpServerPrimitiveCollection<T>reference still seesCount == 0/ empty indexer. That's a latent correctness trap for a workaround whose only goal is "send one notification instead of N." This is a common pattern (progressive disclosure / bulk registration), and every server that does it will need the same workaround. It belongs in the SDK.Proposed API
Add a first-class batching primitive to
McpServerPrimitiveCollection<T>that coalesces mutations into a singleChanged:(Alternate names for API review:
BeginBatch(),BeginChangeBatch().DeferChanges()deliberately echoes WPF'sICollectionView.DeferRefresh(), see Prior art.)Semantics:
Changedfires only when the outermost scope disposes, and only if ≥1 mutation actually occurred.Example (what the nesm registrar would become):
Alternatives considered
TryAddRange(IEnumerable<T>)/ bulk-mutation methods. We considered range APIs that fire oneChangedper call. Rejected: a range method only coalesces a single kind of mutation. Progressive disclosure frequently does adds and removes in one logical operation (e.g. swapping a module's feature set), and a range method leaves that gap — you'd be back to ≥2 notifications, or forced into aReset. A deferral scope coalesces any mix ofTryAdd/Remove/Clearinto one notification. This mirrors whyObservableCollection<T>deliberately has noAddRange—NotifyCollectionChangedEventArgscan't express arbitrary batched mutations and consumers fall back toReset(see dotnet/runtime#585).boolsuspend flag (à laBindingList<T>.RaiseListChangedEvents). Rejected: no auto-restore (leaks the suspended state if an exception skips the reset), not nestable, and forces a manual "fire the coalesced event" call afterward. AnIDisposablescope is exception-safe and composes.Prior art in .NET
The exact shape we want — an
IDisposablescope that suspends change notifications and fires one on dispose — is already proven in the framework by WPF'sICollectionView.DeferRefresh(), which returns anIDisposablethat holds offCollectionChanged/refresh until theusingblock exits. That's the clean, ergonomic model this proposal follows (and the reasonDeferChanges()is the suggested name).Within
dotnet/runtimethe two halves of that idea are well-established, just never combined into one primitive:IDisposablescope with a nesting counter —ObservableCollection<T>.BlockReentrancy()(System.ObjectModel), depth-counted via_blockReentrancyCount. (Used there for reentrancy guarding rather than batching, but it's the same scope mechanism.)BindingList<T>.RaiseListChangedEvents+ResetBindings()(System.ComponentModel.TypeConverter);DataTable.BeginLoadData()/EndLoadData()(System.Data.Common), which suspends notifications during bulk load; and the standardizedISupportInitialize.BeginInit()/EndInit()(System.ComponentModel.Primitives) deferral pattern.Notably, where a framework shipped only a
booltoggle, consumers repeatedly reinvent the disposable wrapper: EF Core exposes justChangeTracker.AutoDetectChangesEnabled, and the community routinely hand-rolls anAutoDetectChangesOffScope : IDisposablearound it. Shipping the scope in the SDK avoids that recurring boilerplate — which is exactly theBatchingToolCollectionsituation above.Secondary ask (optional)
If
DeferChanges()lands, servers no longer need to subclass just to batch. But to make subclassing safe in general, consider either makingCount/IsEmpty/this[string]virtual, or documenting that a subclass overriding the storage members must also shadow these three. The batching API is the priority; this is a note for API review.Compatibility
DeferChanges().McpServerPrimitiveCollection<T>.References
src/Nesm.Mcp/BatchingToolCollection.csand PR Blazor-Playground/nesm#61ICollectionView.DeferRefresh();ObservableCollection<T>.BlockReentrancy(),BindingList<T>.RaiseListChangedEvents,DataTable.BeginLoadData,ISupportInitialize(alldotnet/runtime);ObservableCollectionrange-notification rejection dotnet/runtime#585.ModelContextProtocol1.4.0.CC @lewing