Skip to content

Serve the subscriptions/listen notification stream per SEP-2575 - #495

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:subscriptions_listen
Open

Serve the subscriptions/listen notification stream per SEP-2575#495
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:subscriptions_listen

Conversation

@koic

@koic koic commented Aug 8, 2026

Copy link
Copy Markdown
Member

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).

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.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.

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic koic changed the title Serve the subscriptions/listen Notification Stream per SEP-2575 Serve the subscriptions/listen notification stream per SEP-2575 Aug 8, 2026
@koic
koic force-pushed the subscriptions_listen branch 4 times, most recently from b3f2558 to d449f36 Compare August 8, 2026 09:53
## 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
koic force-pushed the subscriptions_listen branch from d449f36 to 3e92253 Compare August 8, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant