Add public subscriptions/listen server handler (SEP-2575) (#1775)

Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
Copilot-Session: 80653343-5dcb-43cb-89b0-8ac8e572c4f7
This commit is contained in:
Tarek Mahmoud Sayed 2026-08-03 18:31:48 -07:00 • committed by GitHub
commit 81ae6ec6c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 689 additions and 13 deletions

View file

@ -380,6 +380,196 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
Assert.Null(grantedNotifications["resourceSubscriptions"]);
}
[Fact]
public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_StreamsNotificationOverHeldOpenPost()
{
// The built-in stateless handler grants nothing and returns immediately because there is no
// session-wide channel. A custom SubscriptionsListenHandler can instead stream notifications over the
// held-open POST response (the listen request's RelatedTransport), which is the solicited
// server-to-client stream. This is the core scenario of issue #1662.
const string subscribedUri = "resource://test";
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
{
var subscriptionId = request.JsonRpcRequest.Id;
var ack = new JsonRpcNotification
{
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsAcknowledgedNotificationParams
{
Notifications = new SubscriptionsListenNotifications
{
ResourceSubscriptions = request.Params.Notifications.ResourceSubscriptions,
},
},
McpJsonUtilities.DefaultOptions),
};
TagWithSubscriptionId(ack, subscriptionId);
await request.Server.SendMessageAsync(ack, cancellationToken);
var updated = new JsonRpcNotification
{
Method = NotificationMethods.ResourceUpdatedNotification,
Params = new JsonObject { ["uri"] = subscribedUri },
};
TagWithSubscriptionId(updated, subscriptionId);
await request.Server.SendMessageAsync(updated, cancellationToken);
// Complete the stream so the POST response finishes; the buffered notifications flush to the client.
return new EmptyResult();
});
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
await using var client = await ConnectMcpClientAsync();
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
var updatedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification,
(notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; });
var listenRequest = new JsonRpcRequest
{
Method = RequestMethods.SubscriptionsListen,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsListenRequestParams
{
Notifications = new SubscriptionsListenNotifications { ResourceSubscriptions = [subscribedUri] },
},
McpJsonUtilities.DefaultOptions),
};
await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken)
.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
var subscriptionId = GetSubscriptionId(ack);
Assert.NotNull(subscriptionId);
var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.Equal(subscriptionId, GetSubscriptionId(updated));
Assert.Equal(subscribedUri, Assert.IsType<JsonObject>(updated.Params)["uri"]?.GetValue<string>());
}
[Fact]
public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_AdvertisesAndStreamsListChanged()
{
// resources/updated rides on resources.subscribe, which is never suppressed, so it cannot prove the
// listChanged capability is advertised. A custom SubscriptionsListenHandler gives a stateless server a
// way to deliver */list_changed over the held-open POST, so server/discover (the only path a
// 2026-07-28+ client uses) must advertise tools.listChanged rather than dropping it (issue #1662).
Builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })])
.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
{
var subscriptionId = request.JsonRpcRequest.Id;
var ack = new JsonRpcNotification
{
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsAcknowledgedNotificationParams
{
Notifications = new SubscriptionsListenNotifications
{
ToolsListChanged = request.Params.Notifications.ToolsListChanged,
},
},
McpJsonUtilities.DefaultOptions),
};
TagWithSubscriptionId(ack, subscriptionId);
await request.Server.SendMessageAsync(ack, cancellationToken);
var listChanged = new JsonRpcNotification { Method = NotificationMethods.ToolListChangedNotification };
TagWithSubscriptionId(listChanged, subscriptionId);
await request.Server.SendMessageAsync(listChanged, cancellationToken);
return new EmptyResult();
});
// Advertise tools.listChanged so the per-response capability decision has something to preserve.
Builder.Services.Configure<McpServerOptions>(options =>
{
options.Capabilities ??= new();
options.Capabilities.Tools ??= new();
options.Capabilities.Tools.ListChanged = true;
});
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
await using var client = await ConnectMcpClientAsync();
// The stateless server can now deliver tools/list_changed over the custom listen stream, so the
// capability must survive on the server/discover response instead of being cleared.
Assert.True(client.ServerCapabilities.Tools?.ListChanged);
var listChangedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var listChangedReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
(notification, _) => { listChangedChannel.Writer.TryWrite(notification); return default; });
var listenRequest = new JsonRpcRequest
{
Method = RequestMethods.SubscriptionsListen,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsListenRequestParams
{
Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true },
},
McpJsonUtilities.DefaultOptions),
};
await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken)
.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
var listChangedNotification = await listChangedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.NotNull(GetSubscriptionId(listChangedNotification));
}
private static string? GetSubscriptionId(JsonRpcNotification notification)
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId)
{
var paramsObject = notification.Params as JsonObject ?? new JsonObject();
if (paramsObject["_meta"] is not JsonObject meta)
{
meta = new JsonObject();
paramsObject["_meta"] = meta;
}
meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch
{
string stringId => JsonValue.Create(stringId),
long longId => JsonValue.Create(longId),
_ => null,
};
notification.Params = paramsObject;
}
[McpServerTool(Name = "testSamplingErrors")]
public static async Task<string> TestSamplingErrors(McpServer server)
{

View file

@ -147,4 +147,17 @@ public class McpServerBuilderExtensionsHandlerTests
Assert.Equal(handler, options.Handlers.UnsubscribeFromResourcesHandler);
}
[Fact]
public void WithSubscriptionsListenHandler_Sets_Handler()
{
McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult> handler = async (context, token) => new EmptyResult();
_builder.Object.WithSubscriptionsListenHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
Assert.Equal(handler, options.Handlers.SubscriptionsListenHandler);
}
}

View file

@ -0,0 +1,278 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the custom <see cref="McpServerHandlers.SubscriptionsListenHandler"/> (SEP-2575, issue #1662).
/// A custom handler is a full replacement for the built-in <c>subscriptions/listen</c> handling: it owns the
/// stream, sends the acknowledgement itself, streams application-defined notifications, and receives no
/// automatic <c>*/list_changed</c> fan-out. These tests run over the in-memory stream transport exercised by
/// <see cref="ClientServerTestBase"/>.
/// </summary>
public class SubscriptionsListenHandlerTests : ClientServerTestBase
{
private const string CustomResourceUri = "custom://event/1";
// Signalled after the custom handler has sent its acknowledgement and first notification and is waiting
// for cancellation. Lets cancellation tests wait until the handler is actually holding the stream open.
private readonly TaskCompletionSource<bool> _handlerHoldingStream = new(TaskCreationOptions.RunContinuationsAsynchronously);
// Signalled from the custom handler's finally block, proving it observed cancellation and cleaned up.
private readonly TaskCompletionSource<bool> _handlerCleanedUp = new(TaskCreationOptions.RunContinuationsAsynchronously);
public SubscriptionsListenHandlerTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
// Register a tool so the server advertises tools.listChanged; this lets the replacement test below
// trigger a collection change and prove the built-in fan-out no longer runs for the custom handler.
mcpServerBuilder.WithTools<ListenTools>();
mcpServerBuilder.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
{
var subscriptionId = request.JsonRpcRequest.Id;
// SEP-2575 requires the acknowledgement to be the first message on the stream. The custom handler
// owns this: it echoes back the requested filters as granted and tags the ack with the id.
var ack = new JsonRpcNotification
{
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsAcknowledgedNotificationParams { Notifications = request.Params.Notifications },
McpJsonUtilities.DefaultOptions),
};
TagWithSubscriptionId(ack, subscriptionId);
await request.Server.SendMessageAsync(ack, cancellationToken);
// Stream one application-defined notification the built-in handler could never produce on its own,
// proving the handler drives the stream. Tagged with the same subscription id.
var updated = new JsonRpcNotification
{
Method = NotificationMethods.ResourceUpdatedNotification,
Params = new JsonObject { ["uri"] = CustomResourceUri },
};
TagWithSubscriptionId(updated, subscriptionId);
await request.Server.SendMessageAsync(updated, cancellationToken);
_handlerHoldingStream.TrySetResult(true);
try
{
// Remain active for the subscription lifetime, exactly like the built-in handler, until the
// request-scoped token is cancelled (notifications/cancelled on stdio, client disconnect on HTTP).
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = cancellationToken.Register(
static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancelled);
await cancelled.Task;
}
finally
{
_handlerCleanedUp.TrySetResult(true);
}
return new EmptyResult();
});
}
[Fact]
public async Task CustomHandler_July2026_SendsAcknowledgementThenTaggedNotification()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
});
// Capture the acknowledgement and the streamed notification on separate channels. Both must arrive
// tagged with the subscription id. The test does not assert cross-notification arrival order because
// the client dispatches incoming messages concurrently (see McpSessionHandler.ProcessMessagesCoreAsync),
// so handler invocation order is not observable; acknowledgement-first is a server-side/wire guarantee.
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
var updatedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification,
(notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; });
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var listenTask = SendSubscriptionsListenAsync(
client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token);
// The acknowledgement carries the subscription id.
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
var subscriptionId = GetSubscriptionId(ack);
Assert.NotNull(subscriptionId);
// The custom application notification is tagged with the same subscription id.
var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.Equal(subscriptionId, GetSubscriptionId(updated));
Assert.Equal(CustomResourceUri, (updated.Params as JsonObject)?["uri"]?.GetValue<string>());
await CancelSubscriptionAsync(listenCts, listenTask);
}
[Fact]
public async Task CustomHandler_ReplacesBuiltIn_SuppressesAutomaticListChangedFanOut()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
});
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
// Request tools/list_changed. The built-in handler would fan these out; the custom handler replaces it
// and never tracks the subscription, so the SDK must deliver nothing automatically for this change.
var listenTask = SendSubscriptionsListenAsync(
client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token);
// Wait until the custom handler is holding the stream open (ack + first notification already sent).
await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken);
await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
// Mutate the tool collection. With the built-in handler this would deliver a tagged tools/list_changed;
// under the custom replacement handler it must not, because _activeSubscriptions is never populated.
// The list-changed fan-out iterates that (empty) set and completes synchronously during Add, so it
// buffers nothing to send. The ListToolsAsync round-trip then flushes the client read pipeline.
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
await CancelSubscriptionAsync(listenCts, listenTask);
// Completed-and-empty proves nothing was ever delivered, not merely "nothing buffered right now":
// WaitToReadAsync returns false only when the channel is both empty and completed.
toolsChannel.Writer.Complete();
Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task CustomHandler_PreJuly2026_IsRejectedWithMethodNotFound()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
});
var request = new JsonRpcRequest
{
Method = RequestMethods.SubscriptionsListen,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsListenRequestParams { Notifications = new SubscriptionsListenNotifications { ResourcesListChanged = true } },
McpJsonUtilities.DefaultOptions),
};
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
client.SendRequestAsync(request, TestContext.Current.CancellationToken));
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
}
[Fact]
public async Task CustomHandler_OnCancellation_ObservesTokenAndCleansUp()
{
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
{
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
});
// Use an explicit request id so the test can address it in a notifications/cancelled message,
// which is the transport-level cancellation signal for a long-lived subscriptions/listen on stdio
// (an HTTP client would instead disconnect, which cancels the same request-scoped token).
var subscriptionId = new RequestId("listen-cancel-1");
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var listenTask = SendSubscriptionsListenAsync(
client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token, subscriptionId);
// Ensure the handler is actively holding the stream before cancelling.
await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken);
// Deterministically drive server-side cancellation by sending notifications/cancelled for the request
// id. The server routes this to the request-scoped token the handler is observing.
await client.SendMessageAsync(new JsonRpcNotification
{
Method = NotificationMethods.CancelledNotification,
Params = JsonSerializer.SerializeToNode(
new CancelledNotificationParams { RequestId = subscriptionId },
McpJsonUtilities.DefaultOptions),
}, TestContext.Current.CancellationToken);
// The handler observed the token: its cancellation registration ran and its finally block completed.
await _handlerCleanedUp.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
// A cancelled request sends no response, so unblock the client side and confirm it observes cancellation.
await CancelSubscriptionAsync(listenCts, listenTask);
}
private static Task SendSubscriptionsListenAsync(
McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken, RequestId requestId = default)
{
var request = new JsonRpcRequest
{
Method = RequestMethods.SubscriptionsListen,
Params = JsonSerializer.SerializeToNode(
new SubscriptionsListenRequestParams { Notifications = notifications },
McpJsonUtilities.DefaultOptions),
};
if (requestId.Id is not null)
{
request.Id = requestId;
}
return client.SendRequestAsync(request, cancellationToken);
}
private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask)
{
await listenCts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listenTask);
}
private static string? GetSubscriptionId(JsonRpcNotification notification)
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId)
{
var paramsObject = notification.Params as JsonObject ?? new JsonObject();
if (paramsObject["_meta"] is not JsonObject meta)
{
meta = new JsonObject();
paramsObject["_meta"] = meta;
}
meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch
{
string stringId => JsonValue.Create(stringId),
long longId => JsonValue.Create(longId),
_ => null,
};
notification.Params = paramsObject;
}
private sealed class ListenTools
{
[McpServerTool, System.ComponentModel.Description("Echoes the input back to the caller.")]
public static string Echo([System.ComponentModel.Description("The message to echo.")] string message) => message;
}
}