From 51669d342beb034a154f966039fea6797d12dfa1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 22:02:42 +0000
Subject: [PATCH 1/5] Initial plan
From df051c14aa3256761987643deae1851371d5ead4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 22:17:18 +0000
Subject: [PATCH 2/5] Add overload for SubscribeToResourceAsync with handler
delegate
- Add new overloads for SubscribeToResourceAsync that accept a handler delegate
- Handler is invoked only for notifications matching the subscribed resource URI
- Return IAsyncDisposable that unsubscribes and removes the handler when disposed
- Add comprehensive tests for the new functionality
- Update documentation with remarks explaining usage patterns
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Client/McpClient.Methods.cs | 137 ++++++++
.../McpClientResourceSubscriptionTests.cs | 296 ++++++++++++++++++
2 files changed, 433 insertions(+)
create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index d5bbf977b..e346dfdf1 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -561,6 +561,18 @@ public Task SubscribeToResourceAsync(string uri, RequestOptions? options = null,
/// The to monitor for cancellation requests. The default is .
/// The result of the request.
/// is .
+ ///
+ ///
+ /// This method subscribes to resource update notifications but does not register a handler.
+ /// To receive notifications, you must separately call
+ /// with and filter for the specific resource URI.
+ /// To unsubscribe, call and dispose the handler registration.
+ ///
+ ///
+ /// For a simpler API that handles both subscription and notification registration in a single call,
+ /// use .
+ ///
+ ///
public Task SubscribeToResourceAsync(
SubscribeRequestParams requestParams,
CancellationToken cancellationToken = default)
@@ -575,6 +587,131 @@ public Task SubscribeToResourceAsync(
cancellationToken: cancellationToken).AsTask();
}
+ ///
+ /// Subscribes to a resource on the server and registers a handler for notifications when it changes.
+ ///
+ /// The URI of the resource to which to subscribe.
+ /// The handler to invoke when the resource is updated. It receives for the subscribed resource.
+ /// Optional request options including metadata, serialization settings, and progress tracking.
+ /// The to monitor for cancellation requests. The default is .
+ ///
+ /// A task that completes with an that, when disposed, unsubscribes from the resource
+ /// and removes the notification handler.
+ ///
+ /// or is .
+ ///
+ ///
+ /// This method provides a convenient way to subscribe to resource updates and handle notifications in a single call.
+ /// The returned manages both the subscription and the notification handler registration.
+ /// When disposed, it automatically unsubscribes from the resource and removes the handler.
+ ///
+ ///
+ /// The handler will only be invoked for notifications related to the specified resource URI.
+ /// Notifications for other resources are filtered out automatically.
+ ///
+ ///
+ public async Task SubscribeToResourceAsync(
+ Uri uri,
+ Func handler,
+ RequestOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(uri);
+
+ return await SubscribeToResourceAsync(uri.AbsoluteUri, handler, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Subscribes to a resource on the server and registers a handler for notifications when it changes.
+ ///
+ /// The URI of the resource to which to subscribe.
+ /// The handler to invoke when the resource is updated. It receives for the subscribed resource.
+ /// Optional request options including metadata, serialization settings, and progress tracking.
+ /// The to monitor for cancellation requests. The default is .
+ ///
+ /// A task that completes with an that, when disposed, unsubscribes from the resource
+ /// and removes the notification handler.
+ ///
+ /// or is .
+ /// is empty or composed entirely of whitespace.
+ ///
+ ///
+ /// This method provides a convenient way to subscribe to resource updates and handle notifications in a single call.
+ /// The returned manages both the subscription and the notification handler registration.
+ /// When disposed, it automatically unsubscribes from the resource and removes the handler.
+ ///
+ ///
+ /// The handler will only be invoked for notifications related to the specified resource URI.
+ /// Notifications for other resources are filtered out automatically.
+ ///
+ ///
+ public async Task SubscribeToResourceAsync(
+ string uri,
+ Func handler,
+ RequestOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNullOrWhiteSpace(uri);
+ Throw.IfNull(handler);
+
+ // Subscribe to the resource
+ await SubscribeToResourceAsync(uri, options, cancellationToken).ConfigureAwait(false);
+
+ // Register a notification handler that filters for this specific resource
+ IAsyncDisposable handlerRegistration = RegisterNotificationHandler(
+ NotificationMethods.ResourceUpdatedNotification,
+ async (notification, ct) =>
+ {
+ if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ResourceUpdatedNotificationParams) is { } resourceUpdate &&
+ string.Equals(resourceUpdate.Uri, uri, StringComparison.Ordinal))
+ {
+ await handler(resourceUpdate, ct).ConfigureAwait(false);
+ }
+ });
+
+ // Return a disposable that unsubscribes and removes the handler
+ return new ResourceSubscription(this, uri, handlerRegistration, options);
+ }
+
+ ///
+ /// Manages a resource subscription, handling both unsubscription and handler disposal.
+ ///
+ private sealed class ResourceSubscription : IAsyncDisposable
+ {
+ private readonly McpClient _client;
+ private readonly string _uri;
+ private readonly IAsyncDisposable _handlerRegistration;
+ private readonly RequestOptions? _options;
+ private int _disposed;
+
+ public ResourceSubscription(McpClient client, string uri, IAsyncDisposable handlerRegistration, RequestOptions? options)
+ {
+ _client = client;
+ _uri = uri;
+ _handlerRegistration = handlerRegistration;
+ _options = options;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 0)
+ {
+ // Unsubscribe from the resource
+ try
+ {
+ await _client.UnsubscribeFromResourceAsync(_uri, _options).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Swallow exceptions during unsubscribe to ensure handler is still disposed
+ }
+
+ // Dispose the notification handler registration
+ await _handlerRegistration.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
///
/// Unsubscribes from a resource on the server to stop receiving notifications about its changes.
///
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
new file mode 100644
index 000000000..ef40e826c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
@@ -0,0 +1,296 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+
+namespace ModelContextProtocol.Tests.Client;
+
+public class McpClientResourceSubscriptionTests : ClientServerTestBase
+{
+ public McpClientResourceSubscriptionTests(ITestOutputHelper outputHelper)
+ : base(outputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ mcpServerBuilder.WithResources();
+ }
+
+ [McpServerResourceType]
+ private sealed class SubscribableResources
+ {
+ [McpServerResource(UriTemplate = "test://resource/{id}"), Description("A subscribable test resource")]
+ public static string GetResource(string id) => $"Resource content: {id}";
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithHandler_ReceivesNotifications()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string resourceUri = "test://resource/1";
+ var notificationReceived = new TaskCompletionSource();
+
+ // Act
+ await using var subscription = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) =>
+ {
+ notificationReceived.TrySetResult(notification);
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send a notification from the server
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var receivedNotification = await notificationReceived.Task.WaitAsync(cts.Token);
+ Assert.NotNull(receivedNotification);
+ Assert.Equal(resourceUri, receivedNotification.Uri);
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithHandler_FiltersNotificationsByUri()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string subscribedUri = "test://resource/1";
+ const string otherUri = "test://resource/2";
+ var notificationCount = 0;
+ var correctNotificationReceived = new TaskCompletionSource();
+
+ // Act
+ await using var subscription = await client.SubscribeToResourceAsync(
+ subscribedUri,
+ (notification, ct) =>
+ {
+ Interlocked.Increment(ref notificationCount);
+ if (notification.Uri == subscribedUri)
+ {
+ correctNotificationReceived.TrySetResult(true);
+ }
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send notifications for different resources
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = otherUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = subscribedUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await correctNotificationReceived.Task.WaitAsync(cts.Token);
+
+ // Give a small delay to ensure no other notifications are processed
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ // Should only receive the notification for the subscribed URI
+ Assert.Equal(1, notificationCount);
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithHandler_DisposalUnsubscribes()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string resourceUri = "test://resource/1";
+ var notificationCount = 0;
+
+ // Act
+ var subscription = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) =>
+ {
+ Interlocked.Increment(ref notificationCount);
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send a notification - should be received
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken); // Allow time for notification to be processed
+
+ // Dispose the subscription
+ await subscription.DisposeAsync();
+
+ // Send another notification - should NOT be received
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken); // Allow time to ensure notification is not processed
+
+ // Assert - only the first notification should have been received
+ Assert.Equal(1, notificationCount);
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithHandler_UriOverload_ReceivesNotifications()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ var resourceUri = new Uri("test://resource/1");
+ var notificationReceived = new TaskCompletionSource();
+
+ // Act
+ await using var subscription = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) =>
+ {
+ notificationReceived.TrySetResult(notification);
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send a notification from the server
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri.AbsoluteUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var receivedNotification = await notificationReceived.Task.WaitAsync(cts.Token);
+ Assert.NotNull(receivedNotification);
+ Assert.Equal(resourceUri.AbsoluteUri, receivedNotification.Uri);
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithNullHandler_ThrowsArgumentNullException()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await client.SubscribeToResourceAsync(
+ "test://resource/1",
+ handler: null!,
+ cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithNullUri_ThrowsArgumentNullException()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await client.SubscribeToResourceAsync(
+ uri: (Uri)null!,
+ handler: (notification, ct) => default,
+ cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_WithEmptyUri_ThrowsArgumentException()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await client.SubscribeToResourceAsync(
+ uri: "",
+ handler: (notification, ct) => default,
+ cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_MultipleSubscriptions_BothReceiveNotifications()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string uri1 = "test://resource/1";
+ const string uri2 = "test://resource/2";
+ var notification1Received = new TaskCompletionSource();
+ var notification2Received = new TaskCompletionSource();
+
+ // Act
+ await using var subscription1 = await client.SubscribeToResourceAsync(
+ uri1,
+ (notification, ct) =>
+ {
+ if (notification.Uri == uri1)
+ {
+ notification1Received.TrySetResult(true);
+ }
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await using var subscription2 = await client.SubscribeToResourceAsync(
+ uri2,
+ (notification, ct) =>
+ {
+ if (notification.Uri == uri2)
+ {
+ notification2Received.TrySetResult(true);
+ }
+ return default(ValueTask);
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send notifications
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = uri1 },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = uri2 },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, TestContext.Current.CancellationToken);
+ await Task.WhenAll(
+ notification1Received.Task.WaitAsync(combined.Token),
+ notification2Received.Task.WaitAsync(combined.Token));
+
+ Assert.True(await notification1Received.Task);
+ Assert.True(await notification2Received.Task);
+ }
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_DisposalIsIdempotent()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string resourceUri = "test://resource/1";
+
+ var subscription = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) => default,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Act - dispose multiple times
+ await subscription.DisposeAsync();
+ await subscription.DisposeAsync();
+ await subscription.DisposeAsync();
+
+ // Assert - no exception should be thrown
+ Assert.True(true);
+ }
+}
From 9a065c5f6f0a7615b3f5bb15cae38e6cf1b9e9a8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 22:22:16 +0000
Subject: [PATCH 3/5] Fix code review feedback - explicitly pass
CancellationToken.None during disposal
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index e346dfdf1..d609506b4 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -699,7 +699,7 @@ public async ValueTask DisposeAsync()
// Unsubscribe from the resource
try
{
- await _client.UnsubscribeFromResourceAsync(_uri, _options).ConfigureAwait(false);
+ await _client.UnsubscribeFromResourceAsync(_uri, _options, CancellationToken.None).ConfigureAwait(false);
}
catch
{
From c46f73fc4ffdec1eb8df8e33fbb94dd60e67feff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 22:44:09 +0000
Subject: [PATCH 4/5] Address PR feedback on SubscribeToResourceAsync
implementation
- Change Uri overload to non-async to avoid unnecessary async state machine
- Register handler before subscribing and cleanup on subscribe failure
- Use UriTemplateComparer.Instance.Equals for URI comparison
- Invert disposal check to early return pattern
- Use finally block for handler disposal instead of swallowing exceptions
- Add test for multiple handlers on same URI
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Client/McpClient.Methods.cs | 41 ++++++-----
.../McpClientResourceSubscriptionTests.cs | 70 +++++++++++++++++++
2 files changed, 95 insertions(+), 16 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index d609506b4..7a87bacb7 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -610,7 +610,7 @@ public Task SubscribeToResourceAsync(
/// Notifications for other resources are filtered out automatically.
///
///
- public async Task SubscribeToResourceAsync(
+ public Task SubscribeToResourceAsync(
Uri uri,
Func handler,
RequestOptions? options = null,
@@ -618,7 +618,7 @@ public async Task SubscribeToResourceAsync(
{
Throw.IfNull(uri);
- return await SubscribeToResourceAsync(uri.AbsoluteUri, handler, options, cancellationToken).ConfigureAwait(false);
+ return SubscribeToResourceAsync(uri.AbsoluteUri, handler, options, cancellationToken);
}
///
@@ -654,21 +654,30 @@ public async Task SubscribeToResourceAsync(
Throw.IfNullOrWhiteSpace(uri);
Throw.IfNull(handler);
- // Subscribe to the resource
- await SubscribeToResourceAsync(uri, options, cancellationToken).ConfigureAwait(false);
-
// Register a notification handler that filters for this specific resource
IAsyncDisposable handlerRegistration = RegisterNotificationHandler(
NotificationMethods.ResourceUpdatedNotification,
async (notification, ct) =>
{
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ResourceUpdatedNotificationParams) is { } resourceUpdate &&
- string.Equals(resourceUpdate.Uri, uri, StringComparison.Ordinal))
+ UriTemplate.UriTemplateComparer.Instance.Equals(resourceUpdate.Uri, uri))
{
await handler(resourceUpdate, ct).ConfigureAwait(false);
}
});
+ try
+ {
+ // Subscribe to the resource
+ await SubscribeToResourceAsync(uri, options, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ // If subscription fails, unregister the handler before propagating the exception
+ await handlerRegistration.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
// Return a disposable that unsubscribes and removes the handler
return new ResourceSubscription(this, uri, handlerRegistration, options);
}
@@ -694,18 +703,18 @@ public ResourceSubscription(McpClient client, string uri, IAsyncDisposable handl
public async ValueTask DisposeAsync()
{
- if (Interlocked.Exchange(ref _disposed, 1) == 0)
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
- // Unsubscribe from the resource
- try
- {
- await _client.UnsubscribeFromResourceAsync(_uri, _options, CancellationToken.None).ConfigureAwait(false);
- }
- catch
- {
- // Swallow exceptions during unsubscribe to ensure handler is still disposed
- }
+ return;
+ }
+ try
+ {
+ // Unsubscribe from the resource
+ await _client.UnsubscribeFromResourceAsync(_uri, _options, CancellationToken.None).ConfigureAwait(false);
+ }
+ finally
+ {
// Dispose the notification handler registration
await _handlerRegistration.DisposeAsync().ConfigureAwait(false);
}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
index ef40e826c..0398aac9c 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
@@ -293,4 +293,74 @@ public async Task SubscribeToResourceAsync_DisposalIsIdempotent()
// Assert - no exception should be thrown
Assert.True(true);
}
+
+ [Fact]
+ public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNotifications()
+ {
+ // Arrange
+ await using McpClient client = await CreateMcpClientForServer();
+ const string resourceUri = "test://resource/1";
+ var handler1Called = new TaskCompletionSource();
+ var handler2Called = new TaskCompletionSource();
+ var handler1Count = 0;
+ var handler2Count = 0;
+
+ // Act - Create two subscriptions to the same URI
+ await using var subscription1 = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) =>
+ {
+ Interlocked.Increment(ref handler1Count);
+ handler1Called.TrySetResult(true);
+ return default;
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await using var subscription2 = await client.SubscribeToResourceAsync(
+ resourceUri,
+ (notification, ct) =>
+ {
+ Interlocked.Increment(ref handler2Count);
+ handler2Called.TrySetResult(true);
+ return default;
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Send a single notification
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert - Both handlers should be invoked
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, TestContext.Current.CancellationToken);
+ await Task.WhenAll(
+ handler1Called.Task.WaitAsync(combined.Token),
+ handler2Called.Task.WaitAsync(combined.Token));
+
+ Assert.Equal(1, handler1Count);
+ Assert.Equal(1, handler2Count);
+
+ // Dispose one subscription
+ await subscription1.DisposeAsync();
+
+ // Reset the second handler's task completion
+ var handler2CalledAgain = new TaskCompletionSource();
+
+ // Send another notification
+ await Server.SendNotificationAsync(
+ NotificationMethods.ResourceUpdatedNotification,
+ new ResourceUpdatedNotificationParams { Uri = resourceUri },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Wait a bit to see if handler2 gets called again
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ // Assert - Only the second handler should still receive notifications
+ // Handler1 should not have been called again (still 1)
+ Assert.Equal(1, handler1Count);
+ // Handler2 should have been called again (now 2)
+ Assert.Equal(2, handler2Count);
+ }
}
From e8e4b9acd7981e576805f26d48ab76a8f397476f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 23:03:15 +0000
Subject: [PATCH 5/5] Fix test to properly track second handler invocation
- Moved handler2CalledAgain TaskCompletionSource to top of test
- Modified handler2 to set handler2CalledAgain on second invocation
- Replaced Task.Delay with proper WaitAsync on handler2CalledAgain
- Test now correctly validates handler2 is invoked after handler1 disposal
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../McpClientResourceSubscriptionTests.cs | 21 ++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
index 0398aac9c..84ee33f34 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs
@@ -302,6 +302,7 @@ public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNo
const string resourceUri = "test://resource/1";
var handler1Called = new TaskCompletionSource();
var handler2Called = new TaskCompletionSource();
+ var handler2CalledAgain = new TaskCompletionSource();
var handler1Count = 0;
var handler2Count = 0;
@@ -320,8 +321,15 @@ public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNo
resourceUri,
(notification, ct) =>
{
- Interlocked.Increment(ref handler2Count);
- handler2Called.TrySetResult(true);
+ var count = Interlocked.Increment(ref handler2Count);
+ if (count == 1)
+ {
+ handler2Called.TrySetResult(true);
+ }
+ else if (count == 2)
+ {
+ handler2CalledAgain.TrySetResult(true);
+ }
return default;
},
cancellationToken: TestContext.Current.CancellationToken);
@@ -345,17 +353,16 @@ await Task.WhenAll(
// Dispose one subscription
await subscription1.DisposeAsync();
- // Reset the second handler's task completion
- var handler2CalledAgain = new TaskCompletionSource();
-
// Send another notification
await Server.SendNotificationAsync(
NotificationMethods.ResourceUpdatedNotification,
new ResourceUpdatedNotificationParams { Uri = resourceUri },
cancellationToken: TestContext.Current.CancellationToken);
- // Wait a bit to see if handler2 gets called again
- await Task.Delay(100, TestContext.Current.CancellationToken);
+ // Wait for handler2 to be called again
+ using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var combined2 = CancellationTokenSource.CreateLinkedTokenSource(cts2.Token, TestContext.Current.CancellationToken);
+ await handler2CalledAgain.Task.WaitAsync(combined2.Token);
// Assert - Only the second handler should still receive notifications
// Handler1 should not have been called again (still 1)