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

@ -191,6 +191,60 @@ public sealed class McpServerHandlers
/// </remarks>
public McpRequestHandler<UnsubscribeRequestParams, EmptyResult>? UnsubscribeFromResourcesHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.SubscriptionsListen"/> requests (SEP-2575).
/// </summary>
/// <remarks>
/// <para>
/// <c>subscriptions/listen</c> is a long-lived request introduced by the 2026-07-28 protocol revision. The
/// held-open response is a solicited server-to-client stream: the server first acknowledges which
/// subscriptions it will honor and then streams matching notifications until the request is cancelled.
/// Setting this handler lets a server author own that stream directly to implement custom subscription
/// kinds, application-driven <c>resources/updated</c> delivery, or subscriptions backed by their own event
/// source. It is especially useful for stateless Streamable HTTP, where unsolicited notifications are
/// dropped (there is no session-wide channel) but the listen request's response stream can still carry
/// notifications for the duration of the request.
/// </para>
/// <para>
/// This is a <b>full replacement</b> for the built-in <c>subscriptions/listen</c> handler. When set, the
/// SDK does not track the subscription, does not send the acknowledgement, and does not perform any
/// automatic <c>*/list_changed</c> fan-out for the request; the handler is solely responsible for the
/// entire lifetime of the stream. The SDK still enforces protocol-version gating: the handler is only
/// reached when the negotiated protocol revision is 2026-07-28 or later, and is otherwise rejected with
/// <see cref="McpErrorCode.MethodNotFound"/>.
/// </para>
/// <para>
/// An implementation of this handler is responsible for:
/// </para>
/// <list type="bullet">
/// <item><description>
/// Sending exactly one <see cref="NotificationMethods.SubscriptionsAcknowledgedNotification"/> before any
/// subscription events, reporting only the filters it actually honors. Advertised server capabilities must
/// match what the handler will actually deliver.
/// </description></item>
/// <item><description>
/// Tagging every streamed notification with the listen request id under
/// <c>_meta[<see cref="MetaKeys.SubscriptionId"/>]</c> so clients sharing a channel can demultiplex it.
/// </description></item>
/// <item><description>
/// Remaining active for the subscription lifetime and cleaning up when the supplied
/// <see cref="CancellationToken"/> is cancelled (client disconnect on HTTP, or
/// <c>notifications/cancelled</c> on stdio).
/// </description></item>
/// <item><description>
/// Returning <see cref="EmptyResult"/> when it deliberately completes the stream.
/// </description></item>
/// </list>
/// <para>
/// Notifications are sent through the request's server (for example <c>request.Server.SendMessageAsync</c>),
/// which routes them over the request's own response stream. For extension filters not represented by
/// <see cref="SubscriptionsListenRequestParams"/>, the handler can inspect
/// <c>request.JsonRpcRequest.Params</c>. Application services and event buses can be resolved from
/// <c>request.Services</c> or captured by the handler delegate.
/// </para>
/// </remarks>
public McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult>? SubscriptionsListenHandler { get; set; }
/// <summary>
/// Gets or sets the handler for <see cref="RequestMethods.LoggingSetLevel"/> requests.
/// </summary>

View file

@ -117,8 +117,11 @@ internal sealed partial class McpServerImpl : McpServer
}
// A stateful session can push unsolicited list-changed notifications, so subscribe to the
// collection change events. A stateless HTTP server cannot send unsolicited notifications, so
// instead suppress the listChanged capability it would otherwise advertise.
// collection change events. A stateless HTTP server cannot push unsolicited notifications; whether it
// may still advertise the listChanged capability (over a custom subscriptions/listen stream to a
// 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here,
// because the same ServerCapabilities feeds both the legacy initialize handshake (which can never
// deliver it) and server/discover (which can, given a custom handler).
if (HasStatefulTransport())
{
Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification);
@ -136,15 +139,6 @@ internal sealed partial class McpServerImpl : McpServer
}
}
}
else
{
if (ServerCapabilities.Tools is not null)
ServerCapabilities.Tools.ListChanged = null;
if (ServerCapabilities.Prompts is not null)
ServerCapabilities.Prompts.ListChanged = null;
if (ServerCapabilities.Resources is not null)
ServerCapabilities.Resources.ListChanged = null;
}
// And initialize the session. The built-in protocol metadata filters run ahead of any
// user-supplied message filters.
@ -516,6 +510,46 @@ internal sealed partial class McpServerImpl : McpServer
/// <inheritdoc/>
public ServerCapabilities ServerCapabilities { get; }
/// <summary>
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing the
/// <c>listChanged</c> flags the server has no way to honor.
/// </summary>
/// <param name="listenStreamCanDeliverListChanged">
/// <see langword="true"/> when the client this response targets can receive <c>*/list_changed</c>
/// notifications over a <c>subscriptions/listen</c> stream.
/// </param>
/// <remarks>
/// A stateless HTTP server has no session-wide channel to push unsolicited <c>*/list_changed</c>
/// notifications. It can only deliver them over a <c>subscriptions/listen</c> stream, which requires both
/// a 2026-07-28+ client (so the request is reachable at all) and a custom
/// <see cref="McpServerHandlers.SubscriptionsListenHandler"/> to own that stream (the built-in stateless
/// handler grants no notifications). When neither the transport is stateful nor that stream can carry
/// them, the <c>listChanged</c> flags are dropped so the server never advertises a capability it cannot
/// deliver. Everything else (for example <c>resources.subscribe</c>) is preserved.
/// </remarks>
private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged)
{
if (HasStatefulTransport() || listenStreamCanDeliverListChanged)
{
return ServerCapabilities;
}
// Copy onto a fresh instance so the shared ServerCapabilities keeps the authored listChanged flags;
// server/discover with a custom listen handler may still advertise them.
return new ServerCapabilities
{
Experimental = ServerCapabilities.Experimental,
Logging = ServerCapabilities.Logging,
Completions = ServerCapabilities.Completions,
Extensions = ServerCapabilities.Extensions,
Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null },
Resources = ServerCapabilities.Resources is { } resources
? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
: null,
Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null },
};
}
/// <inheritdoc />
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
@ -667,7 +701,11 @@ internal sealed partial class McpServerImpl : McpServer
ProtocolVersion = negotiatedProtocolVersion,
Instructions = options.ServerInstructions,
ServerInfo = options.ServerInfo ?? DefaultImplementation,
Capabilities = ServerCapabilities ?? new(),
// The initialize handshake only serves pre-2026-07-28 clients, which cannot open a
// subscriptions/listen stream, so a stateless server has no way to deliver list-changed
// notifications to them regardless of any custom handler.
Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false),
// resultType is a 2026-07-28 result field. The initialize handshake is only available on
// 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw
@ -694,7 +732,13 @@ internal sealed partial class McpServerImpl : McpServer
return new ValueTask<DiscoverResult>(new DiscoverResult
{
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
Capabilities = ServerCapabilities ?? new(),
// server/discover only serves 2026-07-28+ clients, which can open a subscriptions/listen
// stream. A stateless server can therefore still deliver list-changed notifications if the
// author supplied a custom handler to own that stream (the built-in stateless handler
// grants nothing, so it cannot).
Capabilities = GetAdvertisedCapabilities(
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null),
Instructions = options.ServerInstructions,
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
// the safest values (immediately stale, not shareable) so existing servers keep
@ -722,9 +766,61 @@ internal sealed partial class McpServerImpl : McpServer
/// Subscription-bound notifications carry the listen request's id in their
/// <c>_meta/io.modelcontextprotocol/subscriptionId</c> field per SEP-2575 so clients can demultiplex.
/// </para>
/// <para>
/// A server author may supply a custom <see cref="McpServerHandlers.SubscriptionsListenHandler"/> to take
/// over the stream entirely; see the design notes at the top of this method for the behavior.
/// </para>
/// </remarks>
private void ConfigureSubscriptions(McpServerOptions options)
{
// Design decision 1 of issue #1662 (replacement vs. additive handler): a custom
// SubscriptionsListenHandler is a FULL REPLACEMENT for the built-in subscriptions/listen handler, not
// an additive/composed one. When one is set, that handler exclusively owns the stream: the SDK does
// not track the subscription in _activeSubscriptions, does not send the acknowledgement, and performs
// no automatic */list_changed fan-out for the request. This keeps the SEP-2575 contract trivial to
// honor (exactly one acknowledgement, no duplicate delivery) and mirrors the existing low-level
// replacement handlers such as CallToolWithAlternateHandler. An additive design was rejected because
// two writers on one stream create ambiguity over who sends the single acknowledgement, force the two
// lifetimes to be coordinated, and risk double-tagging the subscription id.
if (options.Handlers.SubscriptionsListenHandler is { } subscriptionsListenHandler)
{
// Route the custom handler through SetHandler so it receives the same DestinationBoundMcpServer as
// every other typed handler. That server sends notifications over this request's own response
// stream (its RelatedTransport), which is what lets the handler stream even under stateless
// Streamable HTTP, where the held-open POST response is the only solicited server-to-client
// channel (the core scenario of issue #1662). Going through SetHandler also applies the standard
// 2026-07-28 resultType stamping and provides the request-scoped service provider via
// request.Services.
SetHandler(RequestMethods.SubscriptionsListen,
(request, cancellationToken) =>
{
// Protocol-version gating stays in the SDK rather than the custom handler, so a custom
// handler can never be reached on a revision that predates SEP-2575. subscriptions/listen
// is a 2026-07-28 feature; on older negotiated revisions it is rejected as an unknown
// method, exactly as the built-in handler below does.
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
{
throw new McpProtocolException(
$"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " +
$"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
McpErrorCode.MethodNotFound);
}
// Notifications is 'required', but that only enforces presence during deserialization,
// not non-nullness: a '{"notifications": null}' payload produces a non-null params object
// with a null Notifications (DefaultOptions does not set RespectNullableAnnotations).
// Normalize null to empty so a custom handler can dereference request.Params.Notifications
// without an NRE, matching the built-in handler's request?.Notifications guard below.
request.Params ??= new SubscriptionsListenRequestParams { Notifications = new() };
request.Params.Notifications ??= new SubscriptionsListenNotifications();
return subscriptionsListenHandler(request, cancellationToken);
},
McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams,
McpJsonUtilities.JsonContext.Default.EmptyResult);
return;
}
_requestHandlers.Set(RequestMethods.SubscriptionsListen,
async (request, jsonRpcRequest, cancellationToken) =>
{

View file

@ -859,6 +859,51 @@ public static partial class McpServerBuilderExtensions
return builder;
}
/// <summary>
/// Configures a handler for <c>subscriptions/listen</c> requests (SEP-2575), taking over the long-lived
/// subscription stream introduced by the 2026-07-28 protocol revision.
/// </summary>
/// <param name="builder">The server builder instance.</param>
/// <param name="handler">The handler that owns the subscription stream for the lifetime of the request.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// <c>subscriptions/listen</c> is a long-lived request whose held-open response is a solicited
/// server-to-client stream. Providing a handler here lets a server author own that stream to implement
/// custom subscription kinds, application-driven <c>resources/updated</c> delivery, or subscriptions backed
/// by their own event source. It is especially useful for stateless Streamable HTTP, where unsolicited
/// notifications are dropped but the listen request's response stream can still carry notifications for the
/// duration of the request.
/// </para>
/// <para>
/// This is a full replacement for the SDK's built-in <c>subscriptions/listen</c> handling. When set, the
/// handler alone is responsible for sending exactly one
/// <see cref="NotificationMethods.SubscriptionsAcknowledgedNotification"/> before any events, tagging every
/// streamed notification with the listen request id under <c>_meta[<see cref="MetaKeys.SubscriptionId"/>]</c>,
/// staying active until the supplied <see cref="CancellationToken"/> is cancelled, and returning
/// <see cref="EmptyResult"/> when it completes. See <see cref="McpServerHandlers.SubscriptionsListenHandler"/>
/// for the full contract.
/// </para>
/// <para>
/// Unlike <see cref="WithSubscribeToResourcesHandler"/>, this method intentionally does not advertise any
/// server capabilities on the author's behalf. The set of notifications a listen handler honors is decided
/// at runtime and can include custom kinds not represented by a capability flag, so the author must
/// configure only the capabilities their handler will actually deliver. Advertised capabilities must match
/// what the handler delivers.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithSubscriptionsListenHandler(this IMcpServerBuilder builder, McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult> handler)
{
Throw.IfNull(builder);
builder.Services.Configure<McpServerOptions>(options =>
{
options.Handlers.SubscriptionsListenHandler = handler;
});
return builder;
}
/// <summary>
/// Configures a handler for processing logging level change requests from clients.
/// </summary>