Background
PR #1610 (default draft protocol: sessionless + handshake-less, SEP-2575) implements the subscriptions/listen list-changed fan-out on the server (McpServerImpl.SendListChangedNotificationAsync). It works end-to-end over stdio (the only stateful transport after that PR) but not over stateless Streamable HTTP, which is the new default.
Problem
In stateless Streamable HTTP each POST is handled as a self-contained request→response with no long-lived server→client channel (the GET stream is not mapped in stateless mode, and StreamableHttpServerTransport { Stateless = true } drops unsolicited notifications). As a result:
- A
subscriptions/listen POST cannot stay open to receive the SEP-2575 acknowledgement or subsequent */list_changed notifications — they are routed to a transport that discards them.
- SDK-generated list-changed notifications (raised by the tool/prompt/resource collection
Changed events) therefore never reach a stateless HTTP client, even when it has an open subscription.
This is a transport/hosting-layer gap, not a gap in the fan-out logic itself. Note the SDK has never supported unsolicited server→client notifications in stateless mode; this is net-new capability, not a regression.
Why the current design doesn't extend to stateless HTTP
The fan-out shipped in #1610 works by:
- Tracking every active subscription in a per-
McpServerImpl dictionary (_activeSubscriptions).
- Subscribing to the shared tool/prompt/resource collection
Changed events.
- On a change, broadcasting to each matching subscription over its captured
RelatedTransport.
This relies on a single, long-lived McpServerImpl instance owning both the subscriptions and the collection-change source. That holds for stdio (one process, one server instance), but in stateless HTTP each request can be served by a different (effectively per-request) server context, so there is no shared instance to hold _activeSubscriptions or to receive the Changed broadcast. Generalizing the current approach would require persisting subscription state at a new hosting layer plus a global cross-request broadcast API — a large, invasive change.
Design direction under consideration (preferred)
Rather than sharing server state and broadcasting unsolicited notifications, treat the subscriptions/listen request as a held-open, solicited channel:
- The
subscriptions/listen handler keeps the POST response stream open and, for its lifetime, observes the relevant collection changes itself, emitting the acknowledgement and subsequent */list_changed notifications as solicited messages routed over that request's RelatedTransport / StreamableHttpPostTransport.
- Because everything is scoped to the in-flight listen request, there is no need for a shared
McpServerImpl instance, a cross-request _activeSubscriptions registry, or a global broadcast API. This mirrors how MRTR / elicitation already deliver solicited server→client messages over RelatedTransport in stateless mode.
- The existing collection
Changed events must keep firing automatically so server authors don't have to do anything; the listen handler is just another consumer of those events for the duration of the open stream.
This is a major rearchitecture and is intentionally out of scope for #1610 — that PR ships the spec-correct stdio behavior and explicitly punts the stateless HTTP path here.
Alternative (heavier)
Keep the broadcast model but lift subscription state into the hosting layer (StreamableHttpHandler) with a new "durable server→client stream" abstraction and a global broadcast API. Considered less desirable because it leaks transport/hosting concerns and reintroduces shared mutable state across requests; captured here for completeness.
Out of scope / related
Acceptance criteria
- A stateless Streamable HTTP client can issue
subscriptions/listen, receive the tagged acknowledgement, and receive subsequent SDK-generated */list_changed notifications (tagged with the subscription id) over the open POST stream.
- SDK-generated list-changed notifications continue to fire automatically from collection changes, with no API change required of server authors.
- No reliance on a shared cross-request
McpServerImpl instance for delivery; layering is preserved (no transport internals exposed through the Core server API).
Background
PR #1610 (default draft protocol: sessionless + handshake-less, SEP-2575) implements the
subscriptions/listenlist-changed fan-out on the server (McpServerImpl.SendListChangedNotificationAsync). It works end-to-end over stdio (the only stateful transport after that PR) but not over stateless Streamable HTTP, which is the new default.Problem
In stateless Streamable HTTP each POST is handled as a self-contained request→response with no long-lived server→client channel (the GET stream is not mapped in stateless mode, and
StreamableHttpServerTransport { Stateless = true }drops unsolicited notifications). As a result:subscriptions/listenPOST cannot stay open to receive the SEP-2575 acknowledgement or subsequent*/list_changednotifications — they are routed to a transport that discards them.Changedevents) therefore never reach a stateless HTTP client, even when it has an open subscription.This is a transport/hosting-layer gap, not a gap in the fan-out logic itself. Note the SDK has never supported unsolicited server→client notifications in stateless mode; this is net-new capability, not a regression.
Why the current design doesn't extend to stateless HTTP
The fan-out shipped in #1610 works by:
McpServerImpldictionary (_activeSubscriptions).Changedevents.RelatedTransport.This relies on a single, long-lived
McpServerImplinstance owning both the subscriptions and the collection-change source. That holds for stdio (one process, one server instance), but in stateless HTTP each request can be served by a different (effectively per-request) server context, so there is no shared instance to hold_activeSubscriptionsor to receive theChangedbroadcast. Generalizing the current approach would require persisting subscription state at a new hosting layer plus a global cross-request broadcast API — a large, invasive change.Design direction under consideration (preferred)
Rather than sharing server state and broadcasting unsolicited notifications, treat the
subscriptions/listenrequest as a held-open, solicited channel:subscriptions/listenhandler keeps the POST response stream open and, for its lifetime, observes the relevant collection changes itself, emitting the acknowledgement and subsequent*/list_changednotifications as solicited messages routed over that request'sRelatedTransport/StreamableHttpPostTransport.McpServerImplinstance, a cross-request_activeSubscriptionsregistry, or a global broadcast API. This mirrors how MRTR / elicitation already deliver solicited server→client messages overRelatedTransportin stateless mode.Changedevents must keep firing automatically so server authors don't have to do anything; the listen handler is just another consumer of those events for the duration of the open stream.This is a major rearchitecture and is intentionally out of scope for #1610 — that PR ships the spec-correct stdio behavior and explicitly punts the stateless HTTP path here.
Alternative (heavier)
Keep the broadcast model but lift subscription state into the hosting layer (
StreamableHttpHandler) with a new "durable server→client stream" abstraction and a global broadcast API. Considered less desirable because it leaks transport/hosting concerns and reintroduces shared mutable state across requests; captured here for completeness.Out of scope / related
resources/updatedremains app-driven (the SDK never generates it;subscribe/unsubscribehandlers default to no-op), so it is not part of the SDK-generated fan-out.Acceptance criteria
subscriptions/listen, receive the tagged acknowledgement, and receive subsequent SDK-generated*/list_changednotifications (tagged with the subscription id) over the open POST stream.McpServerImplinstance for delivery; layering is preserved (no transport internals exposed through the Core server API).