Skip to content

Commit 81ae6ec

Browse files
tarekghTarek Mahmoud Sayed
andauthored
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
1 parent 79e13b3 commit 81ae6ec

6 files changed

Lines changed: 689 additions & 13 deletions

File tree

src/ModelContextProtocol.Core/Server/McpServerHandlers.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,60 @@ public McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult
191191
/// </remarks>
192192
public McpRequestHandler<UnsubscribeRequestParams, EmptyResult>? UnsubscribeFromResourcesHandler { get; set; }
193193

194+
/// <summary>
195+
/// Gets or sets the handler for <see cref="RequestMethods.SubscriptionsListen"/> requests (SEP-2575).
196+
/// </summary>
197+
/// <remarks>
198+
/// <para>
199+
/// <c>subscriptions/listen</c> is a long-lived request introduced by the 2026-07-28 protocol revision. The
200+
/// held-open response is a solicited server-to-client stream: the server first acknowledges which
201+
/// subscriptions it will honor and then streams matching notifications until the request is cancelled.
202+
/// Setting this handler lets a server author own that stream directly to implement custom subscription
203+
/// kinds, application-driven <c>resources/updated</c> delivery, or subscriptions backed by their own event
204+
/// source. It is especially useful for stateless Streamable HTTP, where unsolicited notifications are
205+
/// dropped (there is no session-wide channel) but the listen request's response stream can still carry
206+
/// notifications for the duration of the request.
207+
/// </para>
208+
/// <para>
209+
/// This is a <b>full replacement</b> for the built-in <c>subscriptions/listen</c> handler. When set, the
210+
/// SDK does not track the subscription, does not send the acknowledgement, and does not perform any
211+
/// automatic <c>*/list_changed</c> fan-out for the request; the handler is solely responsible for the
212+
/// entire lifetime of the stream. The SDK still enforces protocol-version gating: the handler is only
213+
/// reached when the negotiated protocol revision is 2026-07-28 or later, and is otherwise rejected with
214+
/// <see cref="McpErrorCode.MethodNotFound"/>.
215+
/// </para>
216+
/// <para>
217+
/// An implementation of this handler is responsible for:
218+
/// </para>
219+
/// <list type="bullet">
220+
/// <item><description>
221+
/// Sending exactly one <see cref="NotificationMethods.SubscriptionsAcknowledgedNotification"/> before any
222+
/// subscription events, reporting only the filters it actually honors. Advertised server capabilities must
223+
/// match what the handler will actually deliver.
224+
/// </description></item>
225+
/// <item><description>
226+
/// Tagging every streamed notification with the listen request id under
227+
/// <c>_meta[<see cref="MetaKeys.SubscriptionId"/>]</c> so clients sharing a channel can demultiplex it.
228+
/// </description></item>
229+
/// <item><description>
230+
/// Remaining active for the subscription lifetime and cleaning up when the supplied
231+
/// <see cref="CancellationToken"/> is cancelled (client disconnect on HTTP, or
232+
/// <c>notifications/cancelled</c> on stdio).
233+
/// </description></item>
234+
/// <item><description>
235+
/// Returning <see cref="EmptyResult"/> when it deliberately completes the stream.
236+
/// </description></item>
237+
/// </list>
238+
/// <para>
239+
/// Notifications are sent through the request's server (for example <c>request.Server.SendMessageAsync</c>),
240+
/// which routes them over the request's own response stream. For extension filters not represented by
241+
/// <see cref="SubscriptionsListenRequestParams"/>, the handler can inspect
242+
/// <c>request.JsonRpcRequest.Params</c>. Application services and event buses can be resolved from
243+
/// <c>request.Services</c> or captured by the handler delegate.
244+
/// </para>
245+
/// </remarks>
246+
public McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult>? SubscriptionsListenHandler { get; set; }
247+
194248
/// <summary>
195249
/// Gets or sets the handler for <see cref="RequestMethods.LoggingSetLevel"/> requests.
196250
/// </summary>

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,11 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
117117
}
118118

119119
// A stateful session can push unsolicited list-changed notifications, so subscribe to the
120-
// collection change events. A stateless HTTP server cannot send unsolicited notifications, so
121-
// instead suppress the listChanged capability it would otherwise advertise.
120+
// collection change events. A stateless HTTP server cannot push unsolicited notifications; whether it
121+
// may still advertise the listChanged capability (over a custom subscriptions/listen stream to a
122+
// 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here,
123+
// because the same ServerCapabilities feeds both the legacy initialize handshake (which can never
124+
// deliver it) and server/discover (which can, given a custom handler).
122125
if (HasStatefulTransport())
123126
{
124127
Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification);
@@ -136,15 +139,6 @@ void Register<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? collection,
136139
}
137140
}
138141
}
139-
else
140-
{
141-
if (ServerCapabilities.Tools is not null)
142-
ServerCapabilities.Tools.ListChanged = null;
143-
if (ServerCapabilities.Prompts is not null)
144-
ServerCapabilities.Prompts.ListChanged = null;
145-
if (ServerCapabilities.Resources is not null)
146-
ServerCapabilities.Resources.ListChanged = null;
147-
}
148142

149143
// And initialize the session. The built-in protocol metadata filters run ahead of any
150144
// user-supplied message filters.
@@ -516,6 +510,46 @@ private void SetNegotiatedProtocolVersion(string protocolVersion)
516510
/// <inheritdoc/>
517511
public ServerCapabilities ServerCapabilities { get; }
518512

513+
/// <summary>
514+
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing the
515+
/// <c>listChanged</c> flags the server has no way to honor.
516+
/// </summary>
517+
/// <param name="listenStreamCanDeliverListChanged">
518+
/// <see langword="true"/> when the client this response targets can receive <c>*/list_changed</c>
519+
/// notifications over a <c>subscriptions/listen</c> stream.
520+
/// </param>
521+
/// <remarks>
522+
/// A stateless HTTP server has no session-wide channel to push unsolicited <c>*/list_changed</c>
523+
/// notifications. It can only deliver them over a <c>subscriptions/listen</c> stream, which requires both
524+
/// a 2026-07-28+ client (so the request is reachable at all) and a custom
525+
/// <see cref="McpServerHandlers.SubscriptionsListenHandler"/> to own that stream (the built-in stateless
526+
/// handler grants no notifications). When neither the transport is stateful nor that stream can carry
527+
/// them, the <c>listChanged</c> flags are dropped so the server never advertises a capability it cannot
528+
/// deliver. Everything else (for example <c>resources.subscribe</c>) is preserved.
529+
/// </remarks>
530+
private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged)
531+
{
532+
if (HasStatefulTransport() || listenStreamCanDeliverListChanged)
533+
{
534+
return ServerCapabilities;
535+
}
536+
537+
// Copy onto a fresh instance so the shared ServerCapabilities keeps the authored listChanged flags;
538+
// server/discover with a custom listen handler may still advertise them.
539+
return new ServerCapabilities
540+
{
541+
Experimental = ServerCapabilities.Experimental,
542+
Logging = ServerCapabilities.Logging,
543+
Completions = ServerCapabilities.Completions,
544+
Extensions = ServerCapabilities.Extensions,
545+
Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null },
546+
Resources = ServerCapabilities.Resources is { } resources
547+
? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
548+
: null,
549+
Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null },
550+
};
551+
}
552+
519553
/// <inheritdoc />
520554
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
521555

@@ -667,7 +701,11 @@ private void ConfigureInitialize(McpServerOptions options)
667701
ProtocolVersion = negotiatedProtocolVersion,
668702
Instructions = options.ServerInstructions,
669703
ServerInfo = options.ServerInfo ?? DefaultImplementation,
670-
Capabilities = ServerCapabilities ?? new(),
704+
705+
// The initialize handshake only serves pre-2026-07-28 clients, which cannot open a
706+
// subscriptions/listen stream, so a stateless server has no way to deliver list-changed
707+
// notifications to them regardless of any custom handler.
708+
Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false),
671709

672710
// resultType is a 2026-07-28 result field. The initialize handshake is only available on
673711
// 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw
@@ -694,7 +732,13 @@ private void ConfigureDiscover(McpServerOptions options)
694732
return new ValueTask<DiscoverResult>(new DiscoverResult
695733
{
696734
SupportedVersions = [.. _perRequestMetadataProtocolVersions],
697-
Capabilities = ServerCapabilities ?? new(),
735+
736+
// server/discover only serves 2026-07-28+ clients, which can open a subscriptions/listen
737+
// stream. A stateless server can therefore still deliver list-changed notifications if the
738+
// author supplied a custom handler to own that stream (the built-in stateless handler
739+
// grants nothing, so it cannot).
740+
Capabilities = GetAdvertisedCapabilities(
741+
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null),
698742
Instructions = options.ServerInstructions,
699743
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
700744
// the safest values (immediately stale, not shareable) so existing servers keep
@@ -722,9 +766,61 @@ private void ConfigureDiscover(McpServerOptions options)
722766
/// Subscription-bound notifications carry the listen request's id in their
723767
/// <c>_meta/io.modelcontextprotocol/subscriptionId</c> field per SEP-2575 so clients can demultiplex.
724768
/// </para>
769+
/// <para>
770+
/// A server author may supply a custom <see cref="McpServerHandlers.SubscriptionsListenHandler"/> to take
771+
/// over the stream entirely; see the design notes at the top of this method for the behavior.
772+
/// </para>
725773
/// </remarks>
726774
private void ConfigureSubscriptions(McpServerOptions options)
727775
{
776+
// Design decision 1 of issue #1662 (replacement vs. additive handler): a custom
777+
// SubscriptionsListenHandler is a FULL REPLACEMENT for the built-in subscriptions/listen handler, not
778+
// an additive/composed one. When one is set, that handler exclusively owns the stream: the SDK does
779+
// not track the subscription in _activeSubscriptions, does not send the acknowledgement, and performs
780+
// no automatic */list_changed fan-out for the request. This keeps the SEP-2575 contract trivial to
781+
// honor (exactly one acknowledgement, no duplicate delivery) and mirrors the existing low-level
782+
// replacement handlers such as CallToolWithAlternateHandler. An additive design was rejected because
783+
// two writers on one stream create ambiguity over who sends the single acknowledgement, force the two
784+
// lifetimes to be coordinated, and risk double-tagging the subscription id.
785+
if (options.Handlers.SubscriptionsListenHandler is { } subscriptionsListenHandler)
786+
{
787+
// Route the custom handler through SetHandler so it receives the same DestinationBoundMcpServer as
788+
// every other typed handler. That server sends notifications over this request's own response
789+
// stream (its RelatedTransport), which is what lets the handler stream even under stateless
790+
// Streamable HTTP, where the held-open POST response is the only solicited server-to-client
791+
// channel (the core scenario of issue #1662). Going through SetHandler also applies the standard
792+
// 2026-07-28 resultType stamping and provides the request-scoped service provider via
793+
// request.Services.
794+
SetHandler(RequestMethods.SubscriptionsListen,
795+
(request, cancellationToken) =>
796+
{
797+
// Protocol-version gating stays in the SDK rather than the custom handler, so a custom
798+
// handler can never be reached on a revision that predates SEP-2575. subscriptions/listen
799+
// is a 2026-07-28 feature; on older negotiated revisions it is rejected as an unknown
800+
// method, exactly as the built-in handler below does.
801+
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
802+
{
803+
throw new McpProtocolException(
804+
$"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " +
805+
$"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
806+
McpErrorCode.MethodNotFound);
807+
}
808+
809+
// Notifications is 'required', but that only enforces presence during deserialization,
810+
// not non-nullness: a '{"notifications": null}' payload produces a non-null params object
811+
// with a null Notifications (DefaultOptions does not set RespectNullableAnnotations).
812+
// Normalize null to empty so a custom handler can dereference request.Params.Notifications
813+
// without an NRE, matching the built-in handler's request?.Notifications guard below.
814+
request.Params ??= new SubscriptionsListenRequestParams { Notifications = new() };
815+
request.Params.Notifications ??= new SubscriptionsListenNotifications();
816+
817+
return subscriptionsListenHandler(request, cancellationToken);
818+
},
819+
McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams,
820+
McpJsonUtilities.JsonContext.Default.EmptyResult);
821+
return;
822+
}
823+
728824
_requestHandlers.Set(RequestMethods.SubscriptionsListen,
729825
async (request, jsonRpcRequest, cancellationToken) =>
730826
{

src/ModelContextProtocol/McpServerBuilderExtensions.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,51 @@ public static IMcpServerBuilder WithUnsubscribeFromResourcesHandler(this IMcpSer
859859
return builder;
860860
}
861861

862+
/// <summary>
863+
/// Configures a handler for <c>subscriptions/listen</c> requests (SEP-2575), taking over the long-lived
864+
/// subscription stream introduced by the 2026-07-28 protocol revision.
865+
/// </summary>
866+
/// <param name="builder">The server builder instance.</param>
867+
/// <param name="handler">The handler that owns the subscription stream for the lifetime of the request.</param>
868+
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
869+
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
870+
/// <remarks>
871+
/// <para>
872+
/// <c>subscriptions/listen</c> is a long-lived request whose held-open response is a solicited
873+
/// server-to-client stream. Providing a handler here lets a server author own that stream to implement
874+
/// custom subscription kinds, application-driven <c>resources/updated</c> delivery, or subscriptions backed
875+
/// by their own event source. It is especially useful for stateless Streamable HTTP, where unsolicited
876+
/// notifications are dropped but the listen request's response stream can still carry notifications for the
877+
/// duration of the request.
878+
/// </para>
879+
/// <para>
880+
/// This is a full replacement for the SDK's built-in <c>subscriptions/listen</c> handling. When set, the
881+
/// handler alone is responsible for sending exactly one
882+
/// <see cref="NotificationMethods.SubscriptionsAcknowledgedNotification"/> before any events, tagging every
883+
/// streamed notification with the listen request id under <c>_meta[<see cref="MetaKeys.SubscriptionId"/>]</c>,
884+
/// staying active until the supplied <see cref="CancellationToken"/> is cancelled, and returning
885+
/// <see cref="EmptyResult"/> when it completes. See <see cref="McpServerHandlers.SubscriptionsListenHandler"/>
886+
/// for the full contract.
887+
/// </para>
888+
/// <para>
889+
/// Unlike <see cref="WithSubscribeToResourcesHandler"/>, this method intentionally does not advertise any
890+
/// server capabilities on the author's behalf. The set of notifications a listen handler honors is decided
891+
/// at runtime and can include custom kinds not represented by a capability flag, so the author must
892+
/// configure only the capabilities their handler will actually deliver. Advertised capabilities must match
893+
/// what the handler delivers.
894+
/// </para>
895+
/// </remarks>
896+
public static IMcpServerBuilder WithSubscriptionsListenHandler(this IMcpServerBuilder builder, McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult> handler)
897+
{
898+
Throw.IfNull(builder);
899+
900+
builder.Services.Configure<McpServerOptions>(options =>
901+
{
902+
options.Handlers.SubscriptionsListenHandler = handler;
903+
});
904+
return builder;
905+
}
906+
862907
/// <summary>
863908
/// Configures a handler for processing logging level change requests from clients.
864909
/// </summary>

0 commit comments

Comments
 (0)