Skip to content

Commit ebed930

Browse files
authored
Merge pull request #495 from koic/subscriptions_listen
Serve the `subscriptions/listen` notification stream per SEP-2575
2 parents b195a21 + c40aa9c commit ebed930

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
@@ -667,6 +667,34 @@ def call(server_context:, **_args)
667667
end
668668
end
669669
end
670+
671+
class TestTriggerToolChange < MCP::Tool
672+
tool_name "test_trigger_tool_change"
673+
description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)"
674+
675+
class << self
676+
# The broadcast alone exercises the `subscriptions/listen` delivery, so no actual
677+
# tool-list mutation is needed, matching the suite's TypeScript reference fixture.
678+
def call(server_context:, **_args)
679+
server_context.notify_tools_list_changed
680+
681+
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
682+
end
683+
end
684+
end
685+
686+
class TestTriggerPromptChange < MCP::Tool
687+
tool_name "test_trigger_prompt_change"
688+
description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)"
689+
690+
class << self
691+
def call(server_context:, **_args)
692+
server_context.notify_prompts_list_changed
693+
694+
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
695+
end
696+
end
697+
end
670698
end
671699

672700
module Prompts
@@ -907,6 +935,8 @@ def build_server
907935
Tools::TestInputRequiredResultCapabilities,
908936
Tools::TestStreamingElicitation,
909937
Tools::TestLoggingTool,
938+
Tools::TestTriggerToolChange,
939+
Tools::TestTriggerPromptChange,
910940
],
911941
prompts: [
912942
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
@@ -941,7 +941,7 @@ def validate_initialize_params!(params)
941941
def discover(_request)
942942
{
943943
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
944-
capabilities: capabilities,
944+
capabilities: discover_capabilities,
945945
instructions: instructions,
946946
_meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
947947
}.compact.merge(
@@ -953,6 +953,21 @@ def discover(_request)
953953
)
954954
end
955955

956+
# Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags
957+
# promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
958+
# (e.g. stdio), matching the Python SDK's era-aware capability derivation.
959+
def discover_capabilities
960+
return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
961+
962+
capabilities.each_with_object({}) do |(name, value), stripped|
963+
stripped[name] = if value.is_a?(Hash)
964+
value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
965+
else
966+
value
967+
end
968+
end
969+
end
970+
956971
def configure_logging_level(request, session: nil)
957972
if capabilities[:logging].nil?
958973
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)

0 commit comments

Comments
 (0)