Serve the subscriptions/listen notification stream per SEP-2575 - #495
Open
koic wants to merge 1 commit into
Open
Serve the subscriptions/listen notification stream per SEP-2575#495koic wants to merge 1 commit into
subscriptions/listen notification stream per SEP-2575#495koic wants to merge 1 commit into
Conversation
subscriptions/listen Notification Stream per SEP-2575subscriptions/listen notification stream per SEP-2575
koic
force-pushed
the
subscriptions_listen
branch
4 times, most recently
from
August 8, 2026 09:53
b3f2558 to
d449f36
Compare
## Motivation and Context Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream; `subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications. The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035); the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321. Wire behavior, per the draft schema: - The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types the client did not request. - The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery (`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping; the mere presence of a primitive's capability is not enough. - Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId` (the listen request id) in `_meta`. - A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream, stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path); an abrupt disconnect sends nothing. Implementation: - `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation) and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream. Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers. The matching snapshot is taken under the transport mutex, but stream writes happen outside it, matching the legacy delivery paths: a slow or stalled subscriber must not block the transport. Duplicate subscription ids close the new stream instead of double-registering. - Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out); a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods. Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain unbounded connections. - stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered, so the method answers `-32601`. - `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`). - The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls (`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either. Out of scope, noted for follow-ups: SSE keepalive pings on listen streams, an external event bus for multi-worker deployments, and the client-side listen driver (the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047). Refs modelcontextprotocol#389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover: the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses), opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, and the 503 past the concurrent stream cap. `test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio, and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that `server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway.
koic
force-pushed
the
subscriptions_listen
branch
from
August 8, 2026 18:38
d449f36 to
3e92253
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream;
subscriptions/listenreplaces it as a long-lived POST that opts in to server change notifications. The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035); the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321.Wire behavior, per the draft schema:
notificationsfilter (SubscriptionFilter):toolsListChanged,promptsListChanged,resourcesListChanged, andresourceSubscriptions(URI list, replacing the legacyresources/subscribeRPC). Every type is opt-in; the server MUST NOT send types the client did not request.notifications/subscriptions/acknowledged, reporting the subset of requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery (listChanged,subscribe), the same derivationserver/discoveruses for its era-aware capability stripping; the mere presence of a primitive's capability is not enough.io.modelcontextprotocol/subscriptionId(the listen request id) in_meta.close) sends aSubscriptionsListenResultresponse before closing the stream, stamped with the REQUIRED 2026-07-28resultTypeat its construction site (it never passes through the dispatch path); an abrupt disconnect sends nothing.Implementation:
StreamableHTTPTransportinterceptssubscriptions/listenon the modern path (after header and envelope validation) and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream. Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks intosend_notificationahead of the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers. The matching snapshot is taken under the transport mutex, but stream writes happen outside it, matching the legacy delivery paths: a slow or stalled subscriber must not block the transport. Duplicate subscription ids close the new stream instead of double-registering.max_listen_subscriptions:, default 1000,nilto opt out); a listen request past the cap is rejected with HTTP 503, like themax_sessionsguard against session floods. Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain unbounded connections.-32601.Server#discoverbecomes era-aware about notification delivery:listChanged/subscribecapability flags promise delivery oversubscriptions/listenstreams in the modern lifecycle, so they are stripped when the transport does not serve that RPC (the newTransport#serves_subscriptions_listen?seam, true forStreamableHTTPTransport).Out of scope, noted for follow-ups: SSE keepalive pings on listen streams, an external event bus for multi-worker deployments, and the client-side listen driver (the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047).
Refs #389.
How Has This Been Tested?
New tests in
test/mcp/server/transports/streamable_http_transport_test.rbcover: the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and for capability entries lacking the delivery flag), thenotificationsfilter and envelope requirements (400 responses), opt-in-only delivery with the correlatingsubscriptionId, URI-scopedresources/updateddelivery, per-subscription ids across concurrent streams, the graceful close result carryingresultType: "complete", duplicate-id rejection, and the 503 past the concurrent stream cap.test/mcp/server/transports/stdio_transport_test.rbasserts-32601over stdio, andtest/mcp/server_test.rbcovers the era-aware capability stripping inserver/discoverfor both transport kinds.bundle exec rake(tests, RuboCop, and conformance baseline) passes.Breaking Changes
None. The method was previously unhandled (
-32601everywhere); the only observable change to existing responses is thatserver/discoverno longer advertiseslistChanged/subscribeflags on transports that cannot deliver those notifications in the modern lifecycle, whichserver/discoverhas not shipped in a gem release with anyway.Types of changes
Checklist