Skip to content

Commit 08e20ed

Browse files
authored
Merge pull request #582 from koic/bound_the_resource_subscriptions_of_a_listen_stream
Bound the resource URIs a `subscriptions/listen` stream retains
2 parents 1811120 + 50a6f3e commit 08e20ed

3 files changed

Lines changed: 269 additions & 22 deletions

File tree

‎docs/_server/subscriptions.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,19 @@ Concurrent streams are capped by `max_listen_subscriptions:` (default 1000; pass
6969
comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil`
7070
when an upstream proxy already keeps the stream alive.
7171

72+
The resource URIs one request names in `resourceSubscriptions` are held for the life of the stream, so the total byte length
73+
of the distinct URIs is bounded by `max_resource_subscription_bytes:` (default 64 KiB, about a thousand ordinary URIs;
74+
pass `nil` to remove the bound). The number of distinct URIs is bounded as well, at 1024, and that bound always applies,
75+
since each retained URI carries a fixed cost however short it is. Duplicates are dropped before either bound is measured,
76+
and the acknowledgement echoes the deduplicated list. A request over either bound is rejected with HTTP 400 and JSON-RPC `-32602`,
77+
as is one whose `resourceSubscriptions` is not an array of strings.
78+
Together with `max_listen_subscriptions`, these bound both the bytes and the number of URIs the listen streams can retain.
79+
7280
```ruby
7381
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
7482
server,
7583
max_listen_subscriptions: 500,
84+
max_resource_subscription_bytes: 16 * 1024,
7685
listen_keepalive_interval: 30,
7786
)
7887
```

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "json"
4+
require "set"
45
require_relative "../../result_type"
56
require_relative "../../transport"
67

@@ -48,6 +49,20 @@ class InvalidJsonError < StandardError; end
4849
# pass `max_listen_subscriptions: nil` to opt out.
4950
DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
5051

52+
# Bound on the bytes of resource URIs one `subscriptions/listen` request may name in `resourceSubscriptions`
53+
# (the sum of their byte lengths after duplicates are dropped). The URIs are held for the life of the stream,
54+
# so without a bound each stream could retain a request body's worth of URIs and the retained total would be
55+
# `max_listen_subscriptions` times `max_request_bytes`. 64 KiB names about a thousand ordinary URIs;
56+
# pass `max_resource_subscription_bytes: nil` to opt out. The specification sets no limit, nor do the TypeScript
57+
# and Python SDKs; this is the same kind of local safety limit as `max_request_bytes`.
58+
DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES = 64 * 1024
59+
60+
# Bound on the number of distinct resource URIs one `subscriptions/listen` request may name. A byte bound does
61+
# not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however
62+
# short it is, so the count is bounded as well. Like `MAX_JSON_NESTING`, it is a structural limit applied
63+
# whatever `max_resource_subscription_bytes` is, including `nil`.
64+
MAX_RESOURCE_SUBSCRIPTION_URIS = 1_024
65+
5166
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
5267
UNSET_IDLE_TIMEOUT = Object.new.freeze
5368
private_constant :UNSET_IDLE_TIMEOUT
@@ -125,6 +140,10 @@ class InvalidJsonError < StandardError; end
125140
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
126141
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
127142
# the cap.
143+
# @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
144+
# resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
145+
# the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
146+
# Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
128147
# @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
129148
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
130149
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
@@ -150,6 +169,7 @@ def initialize(
150169
session_request_validator: nil,
151170
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
152171
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
172+
max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
153173
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
154174
serve_subscriptions_listen: true,
155175
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
@@ -170,8 +190,9 @@ def initialize(
170190
@pending_responses = {}
171191

172192
# Maps a key the transport mints for each `subscriptions/listen` stream to
173-
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_subscription_filter, active: boolean,
174-
# write_mutex: Mutex, keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
193+
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
194+
# resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
195+
# keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
175196
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
176197
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
177198
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
@@ -218,6 +239,12 @@ def initialize(
218239

219240
@max_listen_subscriptions = max_listen_subscriptions
220241

242+
if !max_resource_subscription_bytes.nil? && !(max_resource_subscription_bytes.is_a?(Integer) && max_resource_subscription_bytes > 0)
243+
raise ArgumentError, "max_resource_subscription_bytes must be a positive Integer or nil"
244+
end
245+
246+
@max_resource_subscription_bytes = max_resource_subscription_bytes
247+
221248
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
222249
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
223250
end
@@ -876,6 +903,15 @@ def handle_subscriptions_listen(body)
876903
)
877904
end
878905

906+
if (problem = resource_subscriptions_problem(filter))
907+
return json_rpc_error_response(
908+
status: 400,
909+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
910+
message: "Invalid params: subscriptions/listen #{problem}",
911+
id: request_id,
912+
)
913+
end
914+
879915
# Best-effort cap check before committing to the SSE response; the registration inside
880916
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
881917
# crossing the cap together.
@@ -946,10 +982,18 @@ def listen_sse_body(request_id, honored)
946982
ListenStreamBody.new do |stream|
947983
subscription_key = SecureRandom.uuid
948984
subscription = nil
985+
# The entry keeps the list-changed flags of the honored filter and the URIs as a frozen Set, both built ahead of the registry lock:
986+
# delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
987+
# the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
988+
# the host keeps this response body.
989+
entry_filter = honored.reject { |name, _value| name == :resourceSubscriptions }
990+
resource_uris = Set.new(honored[:resourceSubscriptions] || []).freeze
991+
949992
@mutex.synchronize do
950993
unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
951994
subscription = {
952-
request_id: request_id, stream: stream, filter: honored, active: false, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new
995+
request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
996+
write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
953997
}
954998
@listen_subscriptions[subscription_key] = subscription
955999
end
@@ -1068,12 +1112,36 @@ def honored_filter(filter)
10681112

10691113
subscriptions = filter[:resourceSubscriptions]
10701114
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1071-
honored[:resourceSubscriptions] = subscriptions
1115+
honored[:resourceSubscriptions] = subscriptions.uniq
10721116
end
10731117

10741118
honored
10751119
end
10761120

1121+
# Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
1122+
# so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
1123+
# and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
1124+
# the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
1125+
# the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
1126+
# and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
1127+
def resource_subscriptions_problem(filter)
1128+
return unless filter.key?(:resourceSubscriptions)
1129+
1130+
subscriptions = filter[:resourceSubscriptions]
1131+
unless subscriptions.is_a?(Array) && subscriptions.all? { |uri| uri.is_a?(String) }
1132+
return "`resourceSubscriptions` must be an array of strings"
1133+
end
1134+
1135+
distinct = subscriptions.uniq
1136+
if distinct.size > MAX_RESOURCE_SUBSCRIPTION_URIS
1137+
return "`resourceSubscriptions` exceeds #{MAX_RESOURCE_SUBSCRIPTION_URIS} URIs"
1138+
end
1139+
return unless @max_resource_subscription_bytes
1140+
return if distinct.sum(&:bytesize) <= @max_resource_subscription_bytes
1141+
1142+
"`resourceSubscriptions` exceeds #{@max_resource_subscription_bytes} bytes"
1143+
end
1144+
10771145
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
10781146
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
10791147
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
@@ -1091,24 +1159,16 @@ def deliver_to_listen_subscriptions(method, params)
10911159
field = LISTEN_FILTER_FIELDS[method]
10921160
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
10931161

1094-
# The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
1095-
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1096-
matched = @mutex.synchronize do
1097-
@listen_subscriptions.filter_map do |subscription_key, subscription|
1098-
# An inactive entry has not finished writing its acknowledgement yet;
1099-
# delivering to it would put a notification ahead of the acknowledgement.
1100-
next unless subscription[:active]
1101-
1102-
hit = if field
1103-
subscription[:filter][field]
1104-
else
1105-
uris = subscription[:filter][:resourceSubscriptions]
1106-
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1107-
uris.is_a?(Array) && uris.include?(uri)
1108-
end
1162+
# Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
1163+
# the matching and the stream writes happen outside it, so neither the number of URIs the streams named
1164+
# nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1165+
# An inactive entry has not finished writing its acknowledgement yet;
1166+
# delivering to it would put a notification ahead of the acknowledgement.
1167+
candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
11091168

1110-
[subscription_key, subscription] if hit
1111-
end
1169+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1170+
matched = candidates.select do |_key, subscription|
1171+
field ? subscription[:filter][field] : subscription[:resource_uris].include?(uri)
11121172
end
11131173

11141174
matched.each do |subscription_key, subscription|

0 commit comments

Comments
 (0)