Skip to content

Add batched change notifications to McpServerPrimitiveCollection<T> #1688

Description

@jeffhandley

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 AddRangeNotifyCollectionChangedEventArgs 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 counterObservableCollection<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-mutationBindingList<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

Metadata

Metadata

Labels

P3Nice to haves, rare edge casesenhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions