@@ -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 {
0 commit comments