Bound the resource URIs a subscriptions/listen stream retains - #582
Merged
koic merged 1 commit intoSep 28, 2026
Conversation
## Motivation and Context The specification types the `resourceSubscriptions` member of a `subscriptions/listen` filter as an array of strings, but the transport checked only that it was a non-empty Array and stored it as received, so an element of any type was accepted. The URIs are kept for the life of the stream, and nothing bounded how many bytes of them one request could name beyond the request body cap itself, so what the listen streams retained was bounded only by `max_listen_subscriptions` times `max_request_bytes`. Every `notifications/resources/updated` delivery then searched each stream's Array with `include?` while holding the transport's lock, a cost that grew with the number of retained URIs. The TypeScript and Python SDKs validate the element type through their schemas but bound neither the count nor the size; Python matches through a set. A `resourceSubscriptions` member that is not an array of strings, an explicit `null` included, is now refused with HTTP 400 and JSON-RPC `-32602`, as the TypeScript SDK's schema validation refuses it. The total byte length of the distinct URIs one request names is bounded by a new `max_resource_subscription_bytes:` keyword, 64 KiB by default, which names about a thousand ordinary URIs; a request over the bound is refused the same way, and `nil` removes the bound. A byte bound does not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however short it is, so the number of distinct URIs is bounded as well, at 1024. Like `MAX_JSON_NESTING`, that bound applies whatever `max_resource_subscription_bytes` is, including `nil`; a keyword can follow if a real client ever needs more. Duplicates are dropped before the bounds are measured and before storage. Together with `max_listen_subscriptions`, the bounds limit both the bytes and the number of URIs the listen streams can retain, and the documentation now states them. The URIs are stored as a Set, and delivery matches outside the transport's lock, so neither the matching cost nor the lock hold grows with the URIs a stream named. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` send a `resourceSubscriptions` holding a non-string or `null`, one over the byte bound, one exactly at it, one with duplicates, one over the URI count bound and one exactly at it, and one over the byte bound to a server without the `subscribe` capability, and check the refusals, the acknowledgement, and that the stored entry keeps the URIs only as a set. Against the previous library the non-string and the oversized filters are accepted and retained. Two more send one over the URI count bound under the default byte bound, and a URI whose byte length exceeds its character length. ## Breaking Changes Requests that earlier releases accepted are now refused at the new bounds: a `subscriptions/listen` request naming more than 64 KiB of distinct resource URIs is refused unless `max_resource_subscription_bytes:` is raised or set to `nil`, and one naming more than 1024 distinct resource URIs is refused.
atesgoral
approved these changes
Sep 28, 2026
koic
deleted the
bound_the_resource_subscriptions_of_a_listen_stream
branch
September 28, 2026 19:38
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
The specification types the
resourceSubscriptionsmember of asubscriptions/listenfilter as an array of strings, but the transport checked only that it was a non-empty Array and stored it as received, so an element of any type was accepted. The URIs are kept for the life of the stream, and nothing bounded how many bytes of them one request could name beyond the request body cap itself, so what the listen streams retained was bounded only bymax_listen_subscriptionstimesmax_request_bytes. Everynotifications/resources/updateddelivery then searched each stream's Array withinclude?while holding the transport's lock, a cost that grew with the number of retained URIs. The TypeScript and Python SDKs validate the element type through their schemas but bound neither the count nor the size; Python matches through a set.A
resourceSubscriptionsmember that is not an array of strings, an explicitnullincluded, is now refused with HTTP 400 and JSON-RPC-32602, as the TypeScript SDK's schema validation refuses it. The total byte length of the distinct URIs one request names is bounded by a newmax_resource_subscription_bytes:keyword, 64 KiB by default, which names about a thousand ordinary URIs; a request over the bound is refused the same way, andnilremoves the bound. A byte bound does not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however short it is, so the number of distinct URIs is bounded as well, at 1024. LikeMAX_JSON_NESTING, that bound applies whatevermax_resource_subscription_bytesis, includingnil; a keyword can follow if a real client ever needs more. Duplicates are dropped before the bounds are measured and before storage. Together withmax_listen_subscriptions, the bounds limit both the bytes and the number of URIs the listen streams can retain, and the documentation now states them. The URIs are stored as a Set, and delivery matches outside the transport's lock, so neither the matching cost nor the lock hold grows with the URIs a stream named.How Has This Been Tested?
New tests in
test/mcp/server/transports/streamable_http_transport_test.rbsend aresourceSubscriptionsholding a non-string ornull, one over the byte bound, one exactly at it, one with duplicates, one over the URI count bound and one exactly at it, and one over the byte bound to a server without thesubscribecapability, and check the refusals, the acknowledgement, and that the stored entry keeps the URIs only as a set. Against the previous library the non-string and the oversized filters are accepted and retained. Two more send one over the URI count bound under the default byte bound, and a URI whose byte length exceeds its character length.Breaking Changes
Requests that earlier releases accepted are now refused at the new bounds: a
subscriptions/listenrequest naming more than 64 KiB of distinct resource URIs is refused unlessmax_resource_subscription_bytes:is raised or set tonil, and one naming more than 1024 distinct resource URIs is refused.Types of changes
Checklist