Skip to content

Commit c40aa9c

Browse files
committed
Serve the subscriptions/listen notification stream per SEP-2575
## 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. - Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds (default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream). A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed, so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism. - Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too. - 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: 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, the 503 past the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot, no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no` on both the listen stream and the legacy GET stream. `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.
1 parent b98b783 commit c40aa9c

10 files changed

Lines changed: 719 additions & 4 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ It implements the Model Context Protocol specification, handling model context r
5454
and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates
5555
the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips
5656
the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result
57+
- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET listening stream:
58+
the client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`),
59+
the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message,
60+
and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. Served on the Streamable HTTP modern path;
61+
stdio answers `-32601`. Concurrent streams are capped by `max_listen_subscriptions:` (default 1000), and each stream receives an SSE keepalive
62+
comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil`
63+
when an upstream proxy already keeps the stream alive
5764
- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that
5865
opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for
5966
additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request,

conformance/server.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,34 @@ def call(server_context:, **_args)
633633
end
634634
end
635635
end
636+
637+
class TestTriggerToolChange < MCP::Tool
638+
tool_name "test_trigger_tool_change"
639+
description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)"
640+
641+
class << self
642+
# The broadcast alone exercises the `subscriptions/listen` delivery, so no actual
643+
# tool-list mutation is needed, matching the suite's TypeScript reference fixture.
644+
def call(server_context:, **_args)
645+
server_context.notify_tools_list_changed
646+
647+
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
648+
end
649+
end
650+
end
651+
652+
class TestTriggerPromptChange < MCP::Tool
653+
tool_name "test_trigger_prompt_change"
654+
description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)"
655+
656+
class << self
657+
def call(server_context:, **_args)
658+
server_context.notify_prompts_list_changed
659+
660+
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
661+
end
662+
end
663+
end
636664
end
637665

638666
module Prompts
@@ -872,6 +900,8 @@ def build_server
872900
Tools::TestInputRequiredResultCapabilities,
873901
Tools::TestStreamingElicitation,
874902
Tools::TestLoggingTool,
903+
Tools::TestTriggerToolChange,
904+
Tools::TestTriggerPromptChange,
875905
],
876906
prompts: [
877907
Prompts::TestSimplePrompt,

lib/mcp/methods.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ module Methods
77
LOGGING_SET_LEVEL = "logging/setLevel"
88
# Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
99
SERVER_DISCOVER = "server/discover"
10+
# Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575),
11+
# replacing the legacy HTTP GET listening stream. Served at the transport layer
12+
# (Streamable HTTP modern path); transports without streaming support answer `-32601`.
13+
SUBSCRIPTIONS_LISTEN = "subscriptions/listen"
1014

1115
PROMPTS_GET = "prompts/get"
1216
PROMPTS_LIST = "prompts/list"
@@ -50,6 +54,9 @@ module Methods
5054
NOTIFICATIONS_PROGRESS = "notifications/progress"
5155
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
5256
NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"
57+
# First message on a `subscriptions/listen` stream (SEP-2575): reports the subset
58+
# of requested notification types the server agreed to honor.
59+
NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged"
5360

5461
class MissingRequiredCapabilityError < StandardError
5562
attr_reader :method

lib/mcp/request_envelope.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class RequestEnvelope
1616
# Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle.
1717
# Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format.
1818
LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"
19+
# Notification-side reserved key (SEP-2575): correlates a notification delivered on
20+
# a `subscriptions/listen` stream (and the stream's closing result) with the JSON-RPC id of
21+
# the `subscriptions/listen` request that opened it. Not part of the request envelope triple.
22+
SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId"
1923

2024
# Result-side counterpart of the request envelope: the server's identity rides in
2125
# the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was

lib/mcp/server.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,7 @@ def validate_initialize_params!(params)
867867
def discover(_request)
868868
{
869869
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
870-
capabilities: capabilities,
870+
capabilities: discover_capabilities,
871871
instructions: instructions,
872872
_meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
873873
}.compact.merge(
@@ -879,6 +879,21 @@ def discover(_request)
879879
)
880880
end
881881

882+
# Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags
883+
# promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
884+
# (e.g. stdio), matching the Python SDK's era-aware capability derivation.
885+
def discover_capabilities
886+
return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
887+
888+
capabilities.each_with_object({}) do |(name, value), stripped|
889+
stripped[name] = if value.is_a?(Hash)
890+
value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
891+
else
892+
value
893+
end
894+
end
895+
end
896+
882897
def configure_logging_level(request, session: nil)
883898
if capabilities[:logging].nil?
884899
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)

0 commit comments

Comments
 (0)