Skip to content

Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications#1689

Open
Copilot wants to merge 5 commits into
mainfrom
copilot/add-batched-change-notifications
Open

Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications#1689
Copilot wants to merge 5 commits into
mainfrom
copilot/add-batched-change-notifications

Conversation

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #1688

Registering a batch of N tools/prompts/resources emits N list_changed wire notifications -- one per TryAdd -- instead of one. This can trip clients that debounce or rate-limit notifications, and is wasteful for progressive-disclosure patterns.

Changes

  • DeferChanges() on McpServerPrimitiveCollection<T> -- returns an IDisposable scope that suppresses Changed events. A single notification fires when the outermost scope disposes, only if at least one mutation occurred. Nesting is supported via a depth counter.
  • Thread-safe implementation -- _deferralDepth and _pendingChange are coordinated with Interlocked/Volatile; RaiseChanged() defers when depth > 0, and the DeferralScope dispose is idempotent via Interlocked.Exchange.
  • Unit tests covering: no-mutation scope, single/multiple mutations, mixed add+remove, Clear, nested scopes, idempotent dispose, no-handler, and baseline non-deferred behavior.

Usage

using (options.ToolCollection.DeferChanges())
{
    foreach (var tool in group.Tools)
        options.ToolCollection.TryAdd(tool);
} // exactly one notifications/tools/list_changed emitted here

Purely additive -- no behavior change for callers that never call DeferChanges(). Applies uniformly to tool, prompt, and resource collections.

… 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>
Copilot AI changed the title [WIP] Add batched change notifications to McpServerPrimitiveCollection Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications Jul 2, 2026
Copilot AI requested a review from jeffhandley July 2, 2026 02:59
Comment thread src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs Outdated
- 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>
Copilot AI requested a review from jeffhandley July 2, 2026 03:54
@jeffhandley jeffhandley marked this pull request as ready for review July 2, 2026 03:58
Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>
Copilot AI requested a review from jeffhandley July 2, 2026 04:00
Copilot stopped work on behalf of jeffhandley due to an error July 2, 2026 04:45
Copilot AI requested a review from jeffhandley July 2, 2026 04:45

@tarekgh tarekgh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Faithful implementation of the agreed design, and it picks up the gate-at-RaiseChanged and net-zero-fires points. One correctness issue on the concurrency handling, plus a small doc gap, noted inline.

Comment on lines +77 to +81
if (Volatile.Read(ref _deferralDepth) > 0)
{
Interlocked.Exchange(ref _pendingChange, 1);
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deferral check here and EndDeferral are not atomic together, so a mutation racing with disposal of the last scope can be dropped. If this line reads depth > 0, but EndDeferral then runs Decrement to 0 and Exchange(_pendingChange, 0) before line 79 sets _pendingChange to 1, the pending change is left set with no active scope to flush it, so no notification is ever sent. That is exactly the missed list_changed this PR aims to prevent, so the Interlocked/Volatile approach does not fully deliver the thread-safety claim.

Suggest guarding the depth/pending transitions with a small lock and invoking Changed outside it. Mutation frequency is low, so the lock only wraps a few integer ops and does not affect ConcurrentDictionary throughput:

private readonly object _deferralLock = new();

protected void RaiseChanged()
{
    lock (_deferralLock)
    {
        if (_deferralDepth > 0) { _pendingChange = true; return; }
    }
    Changed?.Invoke(this, EventArgs.Empty);
}

private void EndDeferral()
{
    bool raise;
    lock (_deferralLock)
    {
        raise = --_deferralDepth == 0 && _pendingChange;
        if (raise) _pendingChange = false;
    }
    if (raise) Changed?.Invoke(this, EventArgs.Empty);
}

DeferChanges() should increment _deferralDepth under the same lock. A multithreaded test (N threads adding inside one shared scope, asserting a single Changed) would lock this in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. Replaced the Interlocked/Volatile approach with a lock (_deferralLock) guard around all _deferralDepth and _pendingChange transitions -- DeferChanges(), RaiseChanged(), and EndDeferral(). _pendingChange is now a bool rather than an int. The Changed invocation still happens outside the lock. The concurrency tests (DeferChanges_ConcurrentMutations_FiresExactlyOneChanged and DeferChanges_MutationRacingWithDispose_NotificationNotLost) lock this in.

/// the deferral is ended on dispose. If any mutation occurred before the exception, a single
/// <see cref="Changed"/> notification is raised.
/// </remarks>
public IDisposable DeferChanges()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a remark that mutations from any thread during an open scope are coalesced, and the single notification fires when the outermost scope disposes. The concurrent behavior is asserted in the PR description but not documented on the API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the latest commit -- added a <para> block to the <remarks> on DeferChanges() documenting that mutations from any thread during an open scope are coalesced, the single notification fires on the thread that disposes the outermost scope, and that all deferral state transitions are guarded by an internal lock.

@tarekgh

tarekgh commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

A couple of tests are missing to back the review points, plus a few worth adding:

Required:

  1. Concurrency test: N threads each TryAdd a distinct tool inside one shared DeferChanges() scope, then assert exactly one Changed and Count == N after all scopes dispose. Add a variant where one thread mutates while another disposes the last scope, asserting the notification is not lost. This exercises the thread-safety claim and pairs with the locking fix above (it may be flaky against the current lock-free code and becomes deterministic once locked).
  2. Derived-type coalescing: a subclass that calls RaiseChanged() directly inside a scope, asserting it coalesces to one notification. Nothing currently covers the gate-at-RaiseChanged path that McpServerResourceCollection relies on.

Recommended:

  1. End-to-end: every test counts the in-process Changed event, not the wire message. Add one through a real client/server (see the list_changed pattern in McpServerBuilderExtensionsToolsTests) that batches inside DeferChanges() and asserts the client receives exactly one notifications/tools/list_changed.
  2. Exception-safety: mutate, throw inside the using block, then assert one Changed fired and the next mutation after the scope fires immediately (deferral fully reset). This validates the guarantee stated in the XML docs.
  3. Prompt/resource coverage: all tests use McpServerTool; add one on a prompt or resource collection to confirm the uniform behavior the PR claims.

@jeffhandley

Copy link
Copy Markdown
Contributor

@copilot Please address the following excellent feedback from @tarekgh:

  1. Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications #1689 (comment), where it's proven that the Interlocked.Exchange approach was insufficient and we need to use a lock instead. That means we can use a bool for flagging when there are pending changes vs. the esoteric int approach. This also addresses the idempotent Changed invocation outside a lock feedback noted in Add batched change notifications to McpServerPrimitiveCollection<T> #1688 (comment)
  2. Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications #1689 (comment), which requests adding a <remarks> blurb about threading behavior.
  3. Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications #1689 (comment), which requests:
    a. Adding concurrency tests
    b. Derived-type coalescing, which is also captured as a design shortfall in the Add batched change notifications to McpServerPrimitiveCollection<T> #1688 (comment) comment on the backing issue (after this PR was created)
    c. Add end-to-end functional tests in the appropriate test classes that covers list_changed event payloads for all collections that gain this behavior by using this collection type
    d. Exception-exercising tests that assert the disposal still functions as expected raising the events

Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Please address the following excellent feedback from @tarekgh:

  1. Add DeferChanges() to McpServerPrimitiveCollection for batched change notifications #1689 (comment), where it's proven that the `Interlock...

All addressed in the latest commit:

  1. Concurrency fix -- _deferralDepth and _pendingChange are now guarded by _deferralLock throughout DeferChanges(), RaiseChanged(), and EndDeferral(). _pendingChange is a bool. Changed is invoked outside the lock.
  2. Threading remarks -- added a <para> block to DeferChanges() documenting coalescing across threads and the lock guarantee.
  3. Tests added to McpServerPrimitiveCollectionTests:
    • DeferChanges_ConcurrentMutations_FiresExactlyOneChanged -- 10 threads each adding inside a shared scope; asserts one Changed and Count == 10.
    • DeferChanges_MutationRacingWithDispose_NotificationNotLost -- 200 iterations of a mutation racing concurrently with scope disposal; asserts exactly one Changed every time.
    • DeferChanges_DerivedTypeCallsRaiseChanged_Coalesces / DeferChanges_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately -- subclass calls RaiseChanged() directly; coalesces inside a scope, fires immediately outside.
    • DeferChanges_ExceptionDuringScope_StillFiresChanged / DeferChanges_ExceptionDuringScope_ResumesImmediateNotificationsAfterward -- scope disposes on exception; deferral fully reset afterward.
    • DeferChanges_PromptCollection_MultipleMutations_FiresOneChanged -- prompt collection coverage.
  4. End-to-end tests -- DeferChanges_BatchAddTools_EmitsExactlyOneNotification, DeferChanges_BatchAddPrompts_EmitsExactlyOneNotification, and DeferChanges_BatchAddResources_EmitsExactlyOneNotification added to the respective McpServerBuilderExtensions*Tests classes. Each pins the legacy protocol, batches three adds inside DeferChanges(), waits for exactly one wire notification, does a List* round-trip to let any second notification arrive, then asserts notificationCount == 1.

/// concurrent mutations and concurrent scope disposal are both safe.
/// </para>
/// </remarks>
public IDisposable DeferChanges()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming makes this sound like mutations to the collection are being deferred until the scope closes. Something like DeferChangedEvents might be clearer.

private readonly object _deferralLock = new();

/// <summary>Depth counter for active <see cref="DeferChanges"/> scopes. Positive means notifications are deferred.</summary>
private int _deferralDepth;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_activeDeferralScopes seems more self-describing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add batched change notifications to McpServerPrimitiveCollection<T>

5 participants