Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
/// <summary>Concurrent dictionary of primitives, indexed by their names.</summary>
private readonly ConcurrentDictionary<string, T> _primitives;

/// <summary>Lock protecting <see cref="_activeDeferralScopes"/> and <see cref="_hasDeferredChangeEvents"/>.</summary>
private readonly object _deferralLock = new();

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

/// <summary>Whether a change occurred while notifications were deferred.</summary>
private bool _hasDeferredChangeEvents;

/// <summary>
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
/// </summary>
Expand All @@ -33,8 +42,85 @@ public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = nul
/// <summary>Gets a value that indicates whether there are any primitives in the collection.</summary>
public bool IsEmpty => _primitives.IsEmpty;

/// <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. Multiple scopes may be active simultaneously;
/// the notification fires once all active scopes have been disposed.
/// </summary>
/// <returns>An <see cref="IDisposable"/> that ends the deferral scope when disposed.</returns>
/// <remarks>
/// The scope is exception-safe: even if an exception is thrown inside a <c>using</c> block,
/// the deferral is ended on dispose. If any mutation occurred before the exception, a single
/// <see cref="Changed"/> notification is raised.
/// <para>
/// Mutations from any thread during an open scope are coalesced. A single <see cref="Changed"/>
/// notification fires on the thread that disposes the last active scope, only if at least one
/// mutation occurred. All deferral state transitions are guarded by an internal lock, so
/// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope
/// instance more than once is safe and has no additional effect.
/// </para>
/// </remarks>
public IDisposable DeferChangedEvents()
{
lock (_deferralLock)
{
_activeDeferralScopes++;
}
return new ChangeDeferralScope(this);
}

/// <summary>Raises <see cref="Changed"/> if there are registered handlers.</summary>
protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
/// <remarks>
/// If a <see cref="DeferChangedEvents"/> scope is active, the notification is deferred until all
/// active scopes are disposed. Derived types that override mutation methods and call
/// <see cref="RaiseChanged"/> will automatically participate in deferral.
/// </remarks>
protected void RaiseChanged()
{
lock (_deferralLock)
{
if (_activeDeferralScopes > 0)
{
_hasDeferredChangeEvents = true;
return;
}
}

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

private void EndDeferral()
{
bool raise;
lock (_deferralLock)
{
raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents;
if (raise)
{
_hasDeferredChangeEvents = false;
}
}

if (raise)
{
Changed?.Invoke(this, EventArgs.Empty);
}
}

private sealed class ChangeDeferralScope : IDisposable
{
private McpServerPrimitiveCollection<T>? _collection;

public ChangeDeferralScope(McpServerPrimitiveCollection<T> collection) =>
_collection = collection;

public void Dispose()
{
McpServerPrimitiveCollection<T>? collection = Interlocked.Exchange(ref _collection, null);
collection?.EndDeferral();
}
}

/// <summary>Gets the <typeparamref name="T"/> with the specified <paramref name="name"/> from the collection.</summary>
/// <param name="name">The name of the primitive to retrieve.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,51 @@
Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt");
}

[Fact]
public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification()
{
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
});

var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var serverPrompts = serverOptions.PromptCollection;
Assert.NotNull(serverPrompts);

int notificationCount = 0;
var firstNotification = new TaskCompletionSource();

Check failure on line 191 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource' is inaccessible due to its protection level

Check failure on line 191 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource' is inaccessible due to its protection level

await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) =>
{
if (Interlocked.Increment(ref notificationCount) == 1)
{
firstNotification.TrySetResult();

Check failure on line 197 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource.TrySetResult()' is inaccessible due to its protection level
}
return default;
}))
{
using (serverPrompts.DeferChangedEvents())
{
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1"));
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2"));
serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3"));
}

await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);

Check failure on line 209 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource<VoidResult>.Task' is inaccessible due to its protection level

// Do a round-trip so that any second (erroneous) notification has time to arrive.
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(prompts, t => t.Name == "BatchPrompt1");
Assert.Contains(prompts, t => t.Name == "BatchPrompt2");
Assert.Contains(prompts, t => t.Name == "BatchPrompt3");

Assert.Equal(1, notificationCount);
}
}

[Fact]
public async Task AttributeProperties_Propagated()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,51 @@
Assert.DoesNotContain(resources, t => t.Name == "NewResource");
}

[Fact]
public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotification()
{
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
});

var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var serverResources = serverOptions.ResourceCollection;
Assert.NotNull(serverResources);

int notificationCount = 0;
var firstNotification = new TaskCompletionSource();

Check failure on line 225 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource' is inaccessible due to its protection level

await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) =>
{
if (Interlocked.Increment(ref notificationCount) == 1)
{
firstNotification.TrySetResult();

Check failure on line 231 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource.TrySetResult()' is inaccessible due to its protection level
}
return default;
}))
{
using (serverResources.DeferChangedEvents())
{
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1"));
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2"));
serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3"));
}

await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);

Check failure on line 243 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource<VoidResult>.Task' is inaccessible due to its protection level

// Do a round-trip so that any second (erroneous) notification has time to arrive.
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(resources, t => t.Name == "BatchResource1");
Assert.Contains(resources, t => t.Name == "BatchResource2");
Assert.Contains(resources, t => t.Name == "BatchResource3");

Assert.Equal(1, notificationCount);
}
}

[Fact]
public async Task AttributeProperties_Propagated()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,51 @@
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}

[Fact]
public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification()
{
// Under the 2026-07-28 protocol, list-changed notifications are delivered only over a
// subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast.
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion,
});

var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var serverTools = serverOptions.ToolCollection;
Assert.NotNull(serverTools);

int notificationCount = 0;
var firstNotification = new TaskCompletionSource();

Check failure on line 250 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource' is inaccessible due to its protection level

await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
{
if (Interlocked.Increment(ref notificationCount) == 1)
{
firstNotification.TrySetResult();

Check failure on line 256 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource.TrySetResult()' is inaccessible due to its protection level
}
return default;
}))
{
using (serverTools.DeferChangedEvents())
{
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1"));
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2"));
serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3"));
}

await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken);

Check failure on line 268 in tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest, Debug)

'TaskCompletionSource<VoidResult>.Task' is inaccessible due to its protection level

// Do a round-trip so that any second (erroneous) notification has time to arrive.
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(tools, t => t.Name == "BatchTool1");
Assert.Contains(tools, t => t.Name == "BatchTool2");
Assert.Contains(tools, t => t.Name == "BatchTool3");

Assert.Equal(1, notificationCount);
}
}

[Fact]
public async Task Can_Call_Registered_Tool()
{
Expand Down
Loading
Loading